From 11c0ad24aa302400592a576dc4cc4647fd5937b5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 18:43:10 -0400 Subject: [PATCH 01/12] =?UTF-8?q?feat(server):=20auto-tag=20route=20spans?= =?UTF-8?q?=20with=20route=20params=20(session.id,=20message.id,=20?= =?UTF-8?q?=E2=80=A6)=20(#23189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/server/routes/instance/trace.ts | 35 +++++++++---- .../test/server/trace-attributes.test.ts | 52 +++++++++++++++++++ 2 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/test/server/trace-attributes.test.ts diff --git a/packages/opencode/src/server/routes/instance/trace.ts b/packages/opencode/src/server/routes/instance/trace.ts index 3e1f72d8b242..fca313b745c8 100644 --- a/packages/opencode/src/server/routes/instance/trace.ts +++ b/packages/opencode/src/server/routes/instance/trace.ts @@ -4,18 +4,31 @@ import { AppRuntime } from "@/effect/app-runtime" type AppEnv = Parameters[0] extends Effect.Effect ? R : never +// Build the base span attributes for an HTTP handler: method, path, and every +// matched route param (sessionID, messageID, partID, providerID, ptyID, …) +// prefixed with `opencode.`. This makes each request's root span searchable +// by ID in motel without having to parse the path string. +export interface RequestLike { + readonly req: { + readonly method: string + readonly url: string + param(): Record + } +} + +export function requestAttributes(c: RequestLike): Record { + const attributes: Record = { + "http.method": c.req.method, + "http.path": new URL(c.req.url).pathname, + } + for (const [key, value] of Object.entries(c.req.param())) { + attributes[`opencode.${key}`] = value + } + return attributes +} + export function runRequest(name: string, c: Context, effect: Effect.Effect) { - const url = new URL(c.req.url) - return AppRuntime.runPromise( - effect.pipe( - Effect.withSpan(name, { - attributes: { - "http.method": c.req.method, - "http.path": url.pathname, - }, - }), - ), - ) + return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) }))) } export async function jsonRequest( diff --git a/packages/opencode/test/server/trace-attributes.test.ts b/packages/opencode/test/server/trace-attributes.test.ts new file mode 100644 index 000000000000..376c81fc6269 --- /dev/null +++ b/packages/opencode/test/server/trace-attributes.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test" +import { requestAttributes } from "../../src/server/routes/instance/trace" + +function fakeContext(method: string, url: string, params: Record) { + return { + req: { + method, + url, + param: () => params, + }, + } +} + +describe("requestAttributes", () => { + test("includes http method and path", () => { + const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {})) + expect(attrs["http.method"]).toBe("GET") + expect(attrs["http.path"]).toBe("/session") + }) + + test("strips query string from path", () => { + const attrs = requestAttributes(fakeContext("GET", "http://localhost/file/search?query=foo&limit=10", {})) + expect(attrs["http.path"]).toBe("/file/search") + }) + + test("tags route params with opencode. prefix", () => { + const attrs = requestAttributes( + fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", { + sessionID: "ses_abc", + messageID: "msg_def", + partID: "prt_ghi", + }), + ) + expect(attrs["opencode.sessionID"]).toBe("ses_abc") + expect(attrs["opencode.messageID"]).toBe("msg_def") + expect(attrs["opencode.partID"]).toBe("prt_ghi") + }) + + test("produces no param attributes when no params are matched", () => { + const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {})) + expect(Object.keys(attrs).filter((k) => k.startsWith("opencode."))).toEqual([]) + }) + + test("handles non-ID params (e.g. mcp :name) without mangling", () => { + const attrs = requestAttributes( + fakeContext("POST", "http://localhost/mcp/exa/connect", { + name: "exa", + }), + ) + expect(attrs["opencode.name"]).toBe("exa") + }) +}) From 2b73a08916da91f93e4981f22aad19353c4510e9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 18:47:48 -0400 Subject: [PATCH 02/12] feat(tui): show session ID in sidebar on non-prod channels (#23185) --- packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 4a7b711a032b..6d92752efe36 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync" import { createMemo, Show } from "solid-js" import { useTheme } from "../../context/theme" import { useTuiConfig } from "../../context/tui-config" -import { InstallationVersion } from "@/installation/version" +import { InstallationChannel, InstallationVersion } from "@/installation/version" import { TuiPluginRuntime } from "../../plugin" import { getScrollAcceleration } from "../../util/scroll" @@ -62,6 +62,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {session()!.title} + + {props.sessionID} + {" "} From 1eafb2160a0dfb9b36791811c99047cd50a6c704 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 19:10:34 -0400 Subject: [PATCH 03/12] feat(effect-zod): add catchall (StructWithRest) support to the walker (#23186) --- packages/opencode/src/util/effect-zod.ts | 16 ++++- .../opencode/test/util/effect-zod.test.ts | 69 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 771795ba6826..22c6eda42d8f 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -107,15 +107,27 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny { } function object(ast: SchemaAST.Objects): z.ZodTypeAny { + // Pure record: { [k: string]: V } if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) { const sig = ast.indexSignatures[0] if (sig.parameter._tag !== "String") return fail(ast) return z.record(z.string(), walk(sig.type)) } - if (ast.indexSignatures.length > 0) return fail(ast) + // Pure object with known fields and no index signatures. + if (ast.indexSignatures.length === 0) { + return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)]))) + } - return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)]))) + // Struct with a catchall (StructWithRest): known fields + index signature. + // Only supports a single string-keyed index signature; multi-signature or + // symbol/number keys fall through to fail. + if (ast.indexSignatures.length !== 1) return fail(ast) + const sig = ast.indexSignatures[0] + if (sig.parameter._tag !== "String") return fail(ast) + return z + .object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)]))) + .catchall(walk(sig.type)) } function array(ast: SchemaAST.Arrays): z.ZodTypeAny { diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index ba67a60e6dc0..89234e7265ff 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -263,4 +263,73 @@ describe("util.effect-zod", () => { expect(result.error!.issues[0].message).toBe("missing 'required' key") }) }) + + describe("StructWithRest / catchall", () => { + test("struct with a string-keyed record rest parses known AND extra keys", () => { + const schema = zod( + Schema.StructWithRest( + Schema.Struct({ + apiKey: Schema.optional(Schema.String), + baseURL: Schema.optional(Schema.String), + }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ) + + // Known fields come through as declared + expect(schema.parse({ apiKey: "sk-x" })).toEqual({ apiKey: "sk-x" }) + + // Extra keys are preserved (catchall) + expect( + schema.parse({ + apiKey: "sk-x", + baseURL: "https://api.example.com", + customField: "anything", + nested: { foo: 1 }, + }), + ).toEqual({ + apiKey: "sk-x", + baseURL: "https://api.example.com", + customField: "anything", + nested: { foo: 1 }, + }) + }) + + test("catchall value type constrains the extras", () => { + const schema = zod( + Schema.StructWithRest( + Schema.Struct({ + count: Schema.Number, + }), + [Schema.Record(Schema.String, Schema.Number)], + ), + ) + + // Known field + numeric extras + expect(schema.parse({ count: 10, a: 1, b: 2 })).toEqual({ count: 10, a: 1, b: 2 }) + + // Non-numeric extra is rejected + expect(schema.safeParse({ count: 10, bad: "not a number" }).success).toBe(false) + }) + + test("JSON schema output marks additionalProperties appropriately", () => { + const schema = zod( + Schema.StructWithRest( + Schema.Struct({ + id: Schema.String, + }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ) + const shape = json(schema) as { additionalProperties?: unknown } + // Presence of `additionalProperties` (truthy or a schema) signals catchall. + expect(shape.additionalProperties).not.toBe(false) + expect(shape.additionalProperties).toBeDefined() + }) + + test("plain struct without rest still emits additionalProperties unchanged (regression)", () => { + const schema = zod(Schema.Struct({ id: Schema.String })) + expect(schema.parse({ id: "x" })).toEqual({ id: "x" }) + }) + }) }) From 8d2d871a586d94ce224233f2fd4c06f68277808f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 19:29:33 -0400 Subject: [PATCH 04/12] refactor(server): align route-span attrs with OTel semantic conventions (#23198) --- .../src/server/routes/instance/trace.ts | 21 ++++++++-- .../test/server/trace-attributes.test.ts | 38 +++++++++++++++---- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/trace.ts b/packages/opencode/src/server/routes/instance/trace.ts index fca313b745c8..4c7119ef3ace 100644 --- a/packages/opencode/src/server/routes/instance/trace.ts +++ b/packages/opencode/src/server/routes/instance/trace.ts @@ -5,9 +5,12 @@ import { AppRuntime } from "@/effect/app-runtime" type AppEnv = Parameters[0] extends Effect.Effect ? R : never // Build the base span attributes for an HTTP handler: method, path, and every -// matched route param (sessionID, messageID, partID, providerID, ptyID, …) -// prefixed with `opencode.`. This makes each request's root span searchable -// by ID in motel without having to parse the path string. +// matched route param. Names follow OTel attribute-naming guidance: +// domain-first (`session.id`, `message.id`, …) so they match the existing +// OTel `session.id` semantic convention and the bare `message.id` we +// already emit from Tool.execute. Non-standard route params fall back to +// `opencode.` since those are internal implementation details +// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/). export interface RequestLike { readonly req: { readonly method: string @@ -16,13 +19,23 @@ export interface RequestLike { } } +// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`) +// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any +// other param is namespaced under `opencode.` to avoid colliding with +// standard conventions. +export function paramToAttributeKey(key: string): string { + const m = key.match(/^(.+)ID$/) + if (m) return `${m[1].toLowerCase()}.id` + return `opencode.${key}` +} + export function requestAttributes(c: RequestLike): Record { const attributes: Record = { "http.method": c.req.method, "http.path": new URL(c.req.url).pathname, } for (const [key, value] of Object.entries(c.req.param())) { - attributes[`opencode.${key}`] = value + attributes[paramToAttributeKey(key)] = value } return attributes } diff --git a/packages/opencode/test/server/trace-attributes.test.ts b/packages/opencode/test/server/trace-attributes.test.ts index 376c81fc6269..c6e8005a2066 100644 --- a/packages/opencode/test/server/trace-attributes.test.ts +++ b/packages/opencode/test/server/trace-attributes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { requestAttributes } from "../../src/server/routes/instance/trace" +import { paramToAttributeKey, requestAttributes } from "../../src/server/routes/instance/trace" function fakeContext(method: string, url: string, params: Record) { return { @@ -11,6 +11,25 @@ function fakeContext(method: string, url: string, params: Record } } +describe("paramToAttributeKey", () => { + test("converts fooID to foo.id", () => { + expect(paramToAttributeKey("sessionID")).toBe("session.id") + expect(paramToAttributeKey("messageID")).toBe("message.id") + expect(paramToAttributeKey("partID")).toBe("part.id") + expect(paramToAttributeKey("projectID")).toBe("project.id") + expect(paramToAttributeKey("providerID")).toBe("provider.id") + expect(paramToAttributeKey("ptyID")).toBe("pty.id") + expect(paramToAttributeKey("permissionID")).toBe("permission.id") + expect(paramToAttributeKey("requestID")).toBe("request.id") + expect(paramToAttributeKey("workspaceID")).toBe("workspace.id") + }) + + test("namespaces non-ID params under opencode.", () => { + expect(paramToAttributeKey("name")).toBe("opencode.name") + expect(paramToAttributeKey("slug")).toBe("opencode.slug") + }) +}) + describe("requestAttributes", () => { test("includes http method and path", () => { const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {})) @@ -23,7 +42,7 @@ describe("requestAttributes", () => { expect(attrs["http.path"]).toBe("/file/search") }) - test("tags route params with opencode. prefix", () => { + test("emits OTel-style .id for ID-shaped route params", () => { const attrs = requestAttributes( fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", { sessionID: "ses_abc", @@ -31,22 +50,27 @@ describe("requestAttributes", () => { partID: "prt_ghi", }), ) - expect(attrs["opencode.sessionID"]).toBe("ses_abc") - expect(attrs["opencode.messageID"]).toBe("msg_def") - expect(attrs["opencode.partID"]).toBe("prt_ghi") + expect(attrs["session.id"]).toBe("ses_abc") + expect(attrs["message.id"]).toBe("msg_def") + expect(attrs["part.id"]).toBe("prt_ghi") + // No camelCase leftovers: + expect(attrs["opencode.sessionID"]).toBeUndefined() + expect(attrs["opencode.messageID"]).toBeUndefined() + expect(attrs["opencode.partID"]).toBeUndefined() }) test("produces no param attributes when no params are matched", () => { const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {})) - expect(Object.keys(attrs).filter((k) => k.startsWith("opencode."))).toEqual([]) + expect(Object.keys(attrs).filter((k) => k !== "http.method" && k !== "http.path")).toEqual([]) }) - test("handles non-ID params (e.g. mcp :name) without mangling", () => { + test("namespaces non-ID params under opencode. (e.g. mcp :name)", () => { const attrs = requestAttributes( fakeContext("POST", "http://localhost/mcp/exa/connect", { name: "exa", }), ) expect(attrs["opencode.name"]).toBe("exa") + expect(attrs["name"]).toBeUndefined() }) }) From 0c1ffc6fa9ad79737fe9dcd2e4ab4fb7ee93b106 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 19:29:53 -0400 Subject: [PATCH 05/12] refactor(config): migrate provider (Model + Info) to Effect Schema (#23197) --- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/config/provider.ts | 220 +++++++++++------------ 2 files changed, 109 insertions(+), 113 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a2d62eaa5e6b..bfb0c2f1f40b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -171,7 +171,7 @@ export const Info = z .optional() .describe("Agent configuration, see https://opencode.ai/docs/agents"), provider: z - .record(z.string(), ConfigProvider.Info) + .record(z.string(), ConfigProvider.Info.zod) .optional() .describe("Custom provider configurations and model overrides"), mcp: z diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 877677519f03..4664999de8cf 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,120 +1,116 @@ +import { Schema } from "effect" import z from "zod" +import { zod, ZodOverride } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -export const Model = z - .object({ - id: z.string(), - name: z.string(), - family: z.string().optional(), - release_date: z.string(), - attachment: z.boolean(), - reasoning: z.boolean(), - temperature: z.boolean(), - tool_call: z.boolean(), - interleaved: z - .union([ - z.literal(true), - z - .object({ - field: z.enum(["reasoning_content", "reasoning_details"]), - }) - .strict(), - ]) - .optional(), - cost: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - context_over_200k: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - }) - .optional(), - }) - .optional(), - limit: z.object({ - context: z.number(), - input: z.number().optional(), - output: z.number(), +// Positive integer preserving exact Zod JSON Schema (type: integer, exclusiveMinimum: 0). +const PositiveInt = Schema.Number.annotate({ + [ZodOverride]: z.number().int().positive(), +}) + +export const Model = Schema.Struct({ + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + family: Schema.optional(Schema.String), + release_date: Schema.optional(Schema.String), + attachment: Schema.optional(Schema.Boolean), + reasoning: Schema.optional(Schema.Boolean), + temperature: Schema.optional(Schema.Boolean), + tool_call: Schema.optional(Schema.Boolean), + interleaved: Schema.optional( + Schema.Union([ + Schema.Literal(true), + Schema.Struct({ + field: Schema.Literals(["reasoning_content", "reasoning_details"]), + }), + ]), + ), + cost: Schema.optional( + Schema.Struct({ + input: Schema.Number, + output: Schema.Number, + cache_read: Schema.optional(Schema.Number), + cache_write: Schema.optional(Schema.Number), + context_over_200k: Schema.optional( + Schema.Struct({ + input: Schema.Number, + output: Schema.Number, + cache_read: Schema.optional(Schema.Number), + cache_write: Schema.optional(Schema.Number), + }), + ), + }), + ), + limit: Schema.optional( + Schema.Struct({ + context: Schema.Number, + input: Schema.optional(Schema.Number), + output: Schema.Number, }), - modalities: z - .object({ - input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), - output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), - }) - .optional(), - experimental: z.boolean().optional(), - status: z.enum(["alpha", "beta", "deprecated"]).optional(), - provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), - options: z.record(z.string(), z.any()), - headers: z.record(z.string(), z.string()).optional(), - variants: z - .record( - z.string(), - z - .object({ - disabled: z.boolean().optional().describe("Disable this variant for the model"), - }) - .catchall(z.any()), - ) - .optional() - .describe("Variant-specific configuration"), - }) - .partial() + ), + modalities: Schema.optional( + Schema.Struct({ + input: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))), + output: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))), + }), + ), + experimental: Schema.optional(Schema.Boolean), + status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + provider: Schema.optional(Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) })), + options: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + variants: Schema.optional( + Schema.Record( + Schema.String, + Schema.StructWithRest( + Schema.Struct({ + disabled: Schema.optional(Schema.Boolean).annotate({ description: "Disable this variant for the model" }), + }), + [Schema.Record(Schema.String, Schema.Any)], + ), + ).annotate({ description: "Variant-specific configuration" }), + ), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const Info = z - .object({ - api: z.string().optional(), - name: z.string(), - env: z.array(z.string()), - id: z.string(), - npm: z.string().optional(), - whitelist: z.array(z.string()).optional(), - blacklist: z.array(z.string()).optional(), - options: z - .object({ - apiKey: z.string().optional(), - baseURL: z.string().optional(), - enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"), - setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"), - timeout: z - .union([ - z - .number() - .int() - .positive() - .describe( - "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", - ), - z.literal(false).describe("Disable timeout for this provider entirely."), - ]) - .optional() - .describe( +export class Info extends Schema.Class("ProviderConfig")({ + api: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + id: Schema.optional(Schema.String), + npm: Schema.optional(Schema.String), + whitelist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + blacklist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), + options: Schema.optional( + Schema.StructWithRest( + Schema.Struct({ + apiKey: Schema.optional(Schema.String), + baseURL: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String).annotate({ + description: "GitHub Enterprise URL for copilot authentication", + }), + setCacheKey: Schema.optional(Schema.Boolean).annotate({ + description: "Enable promptCacheKey for this provider (default false)", + }), + timeout: Schema.optional( + Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({ + description: + "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", + }), + ).annotate({ + description: "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", - ), - chunkTimeout: z - .number() - .int() - .positive() - .optional() - .describe( + }), + chunkTimeout: Schema.optional(PositiveInt).annotate({ + description: "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", - ), - }) - .catchall(z.any()) - .optional(), - models: z.record(z.string(), Model).optional(), - }) - .partial() - .strict() - .meta({ - ref: "ProviderConfig", - }) - -export type Info = z.infer + }), + }), + [Schema.Record(Schema.String, Schema.Any)], + ), + ), + models: Schema.optional(Schema.Record(Schema.String, Model)), +}) { + static readonly zod = zod(this) +} export * as ConfigProvider from "./provider" From 280b9d4c8084e17b3bb3cb62e5a394b200b9a34d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 23:30:51 +0000 Subject: [PATCH 06/12] chore: generate --- packages/opencode/src/config/provider.ts | 4 +++- packages/sdk/openapi.json | 8 ++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 4664999de8cf..b435f43759ae 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -56,7 +56,9 @@ export const Model = Schema.Struct({ ), experimental: Schema.optional(Schema.Boolean), status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), - provider: Schema.optional(Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) })), + provider: Schema.optional( + Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), + ), options: Schema.optional(Schema.Record(Schema.String, Schema.Any)), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), variants: Schema.optional( diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index c3fd00356c2a..5a93c4db2a83 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11180,13 +11180,11 @@ "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", "anyOf": [ { - "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 }, { - "description": "Disable timeout for this provider entirely.", "type": "boolean", "const": false } @@ -11247,8 +11245,7 @@ "enum": ["reasoning_content", "reasoning_details"] } }, - "required": ["field"], - "additionalProperties": false + "required": ["field"] } ] }, @@ -11377,8 +11374,7 @@ } } } - }, - "additionalProperties": false + } }, "McpLocalConfig": { "type": "object", From f3d1fd9ce837d9eaad631e484fc63767b9b241b7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 19:55:55 -0400 Subject: [PATCH 07/12] feat(effect-zod): transform support + walk memoization + flattened checks (#23203) --- packages/opencode/src/util/effect-zod.ts | 80 ++++++++-- .../opencode/test/util/effect-zod.test.ts | 148 +++++++++++++++++- 2 files changed, 213 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 22c6eda42d8f..6f46c684be44 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -1,4 +1,4 @@ -import { Schema, SchemaAST } from "effect" +import { Effect, Option, Schema, SchemaAST } from "effect" import z from "zod" /** @@ -8,33 +8,85 @@ import z from "zod" */ export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") +// AST nodes are immutable and frequently shared across schemas (e.g. a single +// Schema.Class embedded in multiple parents). Memoizing by node identity +// avoids rebuilding equivalent Zod subtrees and keeps derived children stable +// by reference across callers. +const walkCache = new WeakMap() + +// Shared empty ParseOptions for the rare callers that need one — avoids +// allocating a fresh object per parse inside refinements and transforms. +const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions + export function zod(schema: S): z.ZodType> { return walk(schema.ast) as z.ZodType> } function walk(ast: SchemaAST.AST): z.ZodTypeAny { + const cached = walkCache.get(ast) + if (cached) return cached + const result = walkUncached(ast) + walkCache.set(ast, result) + return result +} + +function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined if (override) return override - let out = body(ast) - for (const check of ast.checks ?? []) { - out = applyCheck(out, check, ast) - } + // Schema.Class wraps its fields in a Declaration AST plus an encoding that + // constructs the class instance. For the Zod derivation we want the plain + // field shape (the decoded/consumer view), not the class instance — so + // Declarations fall through to body(), not encoded(). User-level + // Schema.decodeTo / Schema.transform attach encoding to non-Declaration + // nodes, where we do apply the transform. + const hasTransform = ast.encoding?.length && ast._tag !== "Declaration" + const base = hasTransform ? encoded(ast) : body(ast) + const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base const desc = SchemaAST.resolveDescription(ast) const ref = SchemaAST.resolveIdentifier(ast) - const next = desc ? out.describe(desc) : out - return ref ? next.meta({ ref }) : next + const described = desc ? out.describe(desc) : out + return ref ? described.meta({ ref }) : described +} + +// Walk the encoded side and apply each link's decode to produce the decoded +// shape. A node `Target` produced by `from.decodeTo(Target)` carries +// `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls +// nest the encoding via `Link.to` so walking it recursively threads all +// prior transforms — typical encoding.length is 1. +function encoded(ast: SchemaAST.AST): z.ZodTypeAny { + const encoding = ast.encoding! + return encoding.reduce((acc, link) => acc.transform((v) => decode(link.transformation, v)), walk(encoding[0].to)) +} + +// Transformations built via pure `SchemaGetter.transform(fn)` (the common +// decodeTo case) resolve synchronously, so running with no services is safe. +// Effectful / middleware-based transforms will surface as Effect defects. +function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown { + const exit = Effect.runSyncExit( + (transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect>, + ) + if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`) + return Option.getOrElse(exit.value, () => value) } -function applyCheck(out: z.ZodTypeAny, check: SchemaAST.Check, ast: SchemaAST.AST): z.ZodTypeAny { - if (check._tag === "FilterGroup") { - return check.checks.reduce((acc, sub) => applyCheck(acc, sub, ast), out) +// Flatten FilterGroups and any nested variants into a linear list of Filters +// so we can run all of them inside a single Zod .superRefine wrapper instead +// of stacking N wrapper layers (one per check). +function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny { + const filters: SchemaAST.Filter[] = [] + const collect = (c: SchemaAST.Check) => { + if (c._tag === "FilterGroup") c.checks.forEach(collect) + else filters.push(c) } + checks.forEach(collect) return out.superRefine((value, ctx) => { - const issue = check.run(value, ast, {} as any) - if (!issue) return - const message = issueMessage(issue) ?? (check.annotations as any)?.message ?? "Validation failed" - ctx.addIssue({ code: "custom", message }) + for (const filter of filters) { + const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS) + if (!issue) continue + const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed" + ctx.addIssue({ code: "custom", message }) + } }) } diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 89234e7265ff..3d72984bfcbe 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Schema } from "effect" +import { Schema, SchemaGetter } from "effect" import z from "zod" import { zod, ZodOverride } from "../../src/util/effect-zod" @@ -332,4 +332,150 @@ describe("util.effect-zod", () => { expect(schema.parse({ id: "x" })).toEqual({ id: "x" }) }) }) + + describe("transforms (Schema.decodeTo)", () => { + test("Number -> pseudo-Duration (seconds) applies the decode function", () => { + // Models the account/account.ts DurationFromSeconds pattern. + const SecondsToMs = Schema.Number.pipe( + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((n: number) => n * 1000), + encode: SchemaGetter.transform((ms: number) => ms / 1000), + }), + ) + + const schema = zod(SecondsToMs) + expect(schema.parse(3)).toBe(3000) + expect(schema.parse(0)).toBe(0) + }) + + test("String -> Number via parseInt decode", () => { + const ParsedInt = Schema.String.pipe( + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)), + encode: SchemaGetter.transform((n: number) => String(n)), + }), + ) + + const schema = zod(ParsedInt) + expect(schema.parse("42")).toBe(42) + expect(schema.parse("0")).toBe(0) + }) + + test("transform inside a struct field applies per-field", () => { + const Field = Schema.Number.pipe( + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((n: number) => n + 1), + encode: SchemaGetter.transform((n: number) => n - 1), + }), + ) + + const schema = zod( + Schema.Struct({ + plain: Schema.Number, + bumped: Field, + }), + ) + + expect(schema.parse({ plain: 5, bumped: 10 })).toEqual({ plain: 5, bumped: 11 }) + }) + + test("chained decodeTo composes transforms in order", () => { + // String -> Number (parseInt) -> Number (doubled). + // Exercises the encoded() reduce, not just a single link. + const Chained = Schema.String.pipe( + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)), + encode: SchemaGetter.transform((n: number) => String(n)), + }), + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((n: number) => n * 2), + encode: SchemaGetter.transform((n: number) => n / 2), + }), + ) + + const schema = zod(Chained) + expect(schema.parse("21")).toBe(42) + expect(schema.parse("0")).toBe(0) + }) + + test("Schema.Class is unaffected by transform walker (returns plain object, not instance)", () => { + // Schema.Class uses Declaration + encoding under the hood to construct + // class instances. The walker must NOT apply that transform, or zod + // parsing would return class instances instead of plain objects. + class Method extends Schema.Class("TxTestMethod")({ + type: Schema.String, + value: Schema.Number, + }) {} + + const schema = zod(Method) + const parsed = schema.parse({ type: "oauth", value: 1 }) + expect(parsed).toEqual({ type: "oauth", value: 1 }) + // Guardrail: ensure we didn't get back a Method instance. + expect(parsed).not.toBeInstanceOf(Method) + }) + }) + + describe("optimizations", () => { + test("walk() memoizes by AST identity — same AST node returns same Zod", () => { + const shared = Schema.Struct({ id: Schema.String, name: Schema.String }) + const left = zod(shared) + const right = zod(shared) + expect(left).toBe(right) + }) + + test("nested reuse of the same AST reuses the cached Zod child", () => { + // Two different parents embed the same inner schema. The inner zod + // child should be identical by reference inside both parents. + class Inner extends Schema.Class("MemoTestInner")({ + value: Schema.String, + }) {} + + class OuterA extends Schema.Class("MemoTestOuterA")({ + inner: Inner, + }) {} + + class OuterB extends Schema.Class("MemoTestOuterB")({ + inner: Inner, + }) {} + + const shapeA = (zod(OuterA) as any).shape ?? (zod(OuterA) as any)._def?.shape?.() + const shapeB = (zod(OuterB) as any).shape ?? (zod(OuterB) as any)._def?.shape?.() + expect(shapeA.inner).toBe(shapeB.inner) + }) + + test("multiple checks run in a single refinement layer (all fire on one value)", () => { + // Three checks attached to the same schema. All three must run and + // report — asserting that no check silently got dropped when we + // flattened into one superRefine. + const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive")) + const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even")) + const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big")) + + const schema = zod(Schema.Number.check(positive).check(even).check(under100)) + + const neg = schema.safeParse(-3) + expect(neg.success).toBe(false) + expect(neg.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"])) + + const big = schema.safeParse(101) + expect(big.success).toBe(false) + expect(big.error!.issues.map((i) => i.message)).toContain("too big") + + // Passing value satisfies all three + expect(schema.parse(42)).toBe(42) + }) + + test("FilterGroup flattens into the single refinement layer alongside its siblings", () => { + const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive")) + const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even")) + const group = Schema.makeFilterGroup([positive, even]) + const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big")) + + const schema = zod(Schema.Number.check(group).check(under100)) + + const bad = schema.safeParse(-3) + expect(bad.success).toBe(false) + expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"])) + }) + }) }) From 6b7f34df20d9f5a3dbc4906226d7bebac297c833 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 17 Apr 2026 23:56:50 +0000 Subject: [PATCH 08/12] chore: generate --- packages/opencode/src/util/effect-zod.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 6f46c684be44..82c661e402a9 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -56,7 +56,10 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { // prior transforms — typical encoding.length is 1. function encoded(ast: SchemaAST.AST): z.ZodTypeAny { const encoding = ast.encoding! - return encoding.reduce((acc, link) => acc.transform((v) => decode(link.transformation, v)), walk(encoding[0].to)) + return encoding.reduce( + (acc, link) => acc.transform((v) => decode(link.transformation, v)), + walk(encoding[0].to), + ) } // Transformations built via pure `SchemaGetter.transform(fn)` (the common @@ -64,7 +67,9 @@ function encoded(ast: SchemaAST.AST): z.ZodTypeAny { // Effectful / middleware-based transforms will surface as Effect defects. function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown { const exit = Effect.runSyncExit( - (transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect>, + (transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect< + Option.Option + >, ) if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`) return Option.getOrElse(exit.value, () => value) From 00120c32a8dd05ba050436226f15d39c84ede00a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 20:40:26 -0400 Subject: [PATCH 09/12] refactor(lsp): effectify client and server boundaries --- packages/opencode/src/config/lsp.ts | 2 +- packages/opencode/src/lsp/client.ts | 313 ++++++++++++---------- packages/opencode/src/lsp/lsp.ts | 283 +++++++++---------- packages/opencode/src/lsp/server.ts | 143 +++++++--- packages/opencode/test/lsp/client.test.ts | 43 +-- 5 files changed, 444 insertions(+), 340 deletions(-) diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 1cf93177e41d..1b63ca2744c7 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -29,7 +29,7 @@ export const requiresExtensionsForCustomServers = Schema.makeFilter< boolean | Record> >((data) => { if (typeof data === "boolean") return undefined - const serverIds = new Set(Object.values(LSPServer).map((server) => server.id)) + const serverIds = new Set(Object.values(LSPServer.Builtins).map((server) => server.id)) const ok = Object.entries(data).every(([id, config]) => { if ("disabled" in config && config.disabled) return true if (serverIds.has(id)) return true diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 59a64ca1ed2e..bfa60f9583f6 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -4,6 +4,7 @@ import path from "path" import { pathToFileURL, fileURLToPath } from "url" import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node" import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types" +import { Effect } from "effect" import { Log } from "../util" import { Process } from "../util" import { LANGUAGE_EXTENSIONS } from "./language" @@ -18,7 +19,19 @@ const DIAGNOSTICS_DEBOUNCE_MS = 150 const log = Log.create({ service: "lsp.client" }) -export type Info = NonNullable>> +type Connection = ReturnType + +export interface Info { + readonly root: string + readonly serverID: string + readonly connection: Connection + readonly notify: { + readonly open: (input: { path: string }) => Effect.Effect + } + readonly diagnostics: Map + readonly waitForDiagnostics: (input: { path: string }) => Effect.Effect + readonly shutdown: () => Effect.Effect +} export type Diagnostic = VSCodeDiagnostic @@ -39,7 +52,11 @@ export const Event = { ), } -export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { +export const create = Effect.fn("LSPClient.create")(function* (input: { + serverID: string + server: LSPServer.Handle + root: string +}) { const l = log.clone().tag("serverID", input.serverID) l.info("starting client") @@ -64,10 +81,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle l.info("window/workDoneProgress/create", params) return null }) - connection.onRequest("workspace/configuration", async () => { - // Return server initialization options - return [input.server.initialization ?? {}] - }) + connection.onRequest("workspace/configuration", async () => [input.server.initialization ?? {}]) connection.onRequest("client/registerCapability", async () => {}) connection.onRequest("client/unregisterCapability", async () => {}) connection.onRequest("workspace/workspaceFolders", async () => [ @@ -79,145 +93,144 @@ export async function create(input: { serverID: string; server: LSPServer.Handle connection.listen() l.info("sending initialize") - await withTimeout( - connection.sendRequest("initialize", { - rootUri: pathToFileURL(input.root).href, - processId: input.server.process.pid, - workspaceFolders: [ - { - name: "workspace", - uri: pathToFileURL(input.root).href, - }, - ], - initializationOptions: { - ...input.server.initialization, - }, - capabilities: { - window: { - workDoneProgress: true, - }, - workspace: { - configuration: true, - didChangeWatchedFiles: { - dynamicRegistration: true, - }, - }, - textDocument: { - synchronization: { - didOpen: true, - didChange: true, + yield* Effect.tryPromise({ + try: () => + withTimeout( + connection.sendRequest("initialize", { + rootUri: pathToFileURL(input.root).href, + processId: input.server.process.pid, + workspaceFolders: [ + { + name: "workspace", + uri: pathToFileURL(input.root).href, + }, + ], + initializationOptions: { + ...input.server.initialization, }, - publishDiagnostics: { - versionSupport: true, + capabilities: { + window: { + workDoneProgress: true, + }, + workspace: { + configuration: true, + didChangeWatchedFiles: { + dynamicRegistration: true, + }, + }, + textDocument: { + synchronization: { + didOpen: true, + didChange: true, + }, + publishDiagnostics: { + versionSupport: true, + }, + }, }, + }), + 45_000, + ), + catch: (error) => { + l.error("initialize error", { error }) + return new InitializeError( + { serverID: input.serverID }, + { + cause: error, }, - }, - }), - 45_000, - ).catch((err) => { - l.error("initialize error", { error: err }) - throw new InitializeError( - { serverID: input.serverID }, - { - cause: err, - }, - ) + ) + }, }) - await connection.sendNotification("initialized", {}) + yield* Effect.tryPromise(() => connection.sendNotification("initialized", {})) if (input.server.initialization) { - await connection.sendNotification("workspace/didChangeConfiguration", { - settings: input.server.initialization, - }) + yield* Effect.tryPromise(() => + connection.sendNotification("workspace/didChangeConfiguration", { + settings: input.server.initialization, + }), + ) } - const files: { - [path: string]: number - } = {} - - const result = { - root: input.root, - get serverID() { - return input.serverID - }, - get connection() { - return connection - }, - notify: { - async open(input: { path: string }) { - input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) - const text = await Filesystem.readText(input.path) - const extension = path.extname(input.path) - const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" - - const version = files[input.path] - if (version !== undefined) { - log.info("workspace/didChangeWatchedFiles", input) - await connection.sendNotification("workspace/didChangeWatchedFiles", { - changes: [ - { - uri: pathToFileURL(input.path).href, - type: 2, // Changed - }, - ], - }) + const files: Record = {} - const next = version + 1 - files[input.path] = next - log.info("textDocument/didChange", { - path: input.path, - version: next, - }) - await connection.sendNotification("textDocument/didChange", { - textDocument: { - uri: pathToFileURL(input.path).href, - version: next, - }, - contentChanges: [{ text }], - }) - return - } + const open = Effect.fn("LSPClient.notify.open")(function* (next: { path: string }) { + next.path = path.isAbsolute(next.path) ? next.path : path.resolve(Instance.directory, next.path) + const text = yield* Effect.promise(() => Filesystem.readText(next.path)).pipe(Effect.orDie) + const extension = path.extname(next.path) + const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" - log.info("workspace/didChangeWatchedFiles", input) - await connection.sendNotification("workspace/didChangeWatchedFiles", { + const version = files[next.path] + if (version !== undefined) { + log.info("workspace/didChangeWatchedFiles", next) + yield* Effect.tryPromise(() => + connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { - uri: pathToFileURL(input.path).href, - type: 1, // Created + uri: pathToFileURL(next.path).href, + type: 2, }, ], - }) + }), + ).pipe(Effect.orDie) - log.info("textDocument/didOpen", input) - diagnostics.delete(input.path) - await connection.sendNotification("textDocument/didOpen", { + const nextVersion = version + 1 + files[next.path] = nextVersion + log.info("textDocument/didChange", { + path: next.path, + version: nextVersion, + }) + yield* Effect.tryPromise(() => + connection.sendNotification("textDocument/didChange", { textDocument: { - uri: pathToFileURL(input.path).href, - languageId, - version: 0, - text, + uri: pathToFileURL(next.path).href, + version: nextVersion, }, - }) - files[input.path] = 0 - return - }, - }, - get diagnostics() { - return diagnostics - }, - async waitForDiagnostics(input: { path: string }) { - const normalizedPath = Filesystem.normalizePath( - path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path), - ) - log.info("waiting for diagnostics", { path: normalizedPath }) - let unsub: () => void - let debounceTimer: ReturnType | undefined - return await withTimeout( + contentChanges: [{ text }], + }), + ).pipe(Effect.orDie) + return + } + + log.info("workspace/didChangeWatchedFiles", next) + yield* Effect.tryPromise(() => + connection.sendNotification("workspace/didChangeWatchedFiles", { + changes: [ + { + uri: pathToFileURL(next.path).href, + type: 1, + }, + ], + }), + ).pipe(Effect.orDie) + + log.info("textDocument/didOpen", next) + diagnostics.delete(next.path) + yield* Effect.tryPromise(() => + connection.sendNotification("textDocument/didOpen", { + textDocument: { + uri: pathToFileURL(next.path).href, + languageId, + version: 0, + text, + }, + }), + ).pipe(Effect.orDie) + files[next.path] = 0 + }) + + const waitForDiagnostics = Effect.fn("LSPClient.waitForDiagnostics")(function* (next: { path: string }) { + const normalizedPath = Filesystem.normalizePath( + path.isAbsolute(next.path) ? next.path : path.resolve(Instance.directory, next.path), + ) + log.info("waiting for diagnostics", { path: normalizedPath }) + let unsub: (() => void) | undefined + let debounceTimer: ReturnType | undefined + yield* Effect.promise(() => + withTimeout( new Promise((resolve) => { unsub = Bus.subscribe(Event.Diagnostics, (event) => { - if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) { - // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax) + if (event.properties.path === normalizedPath && event.properties.serverID === input.serverID) { if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = setTimeout(() => { log.info("got diagnostics", { path: normalizedPath }) @@ -228,23 +241,43 @@ export async function create(input: { serverID: string; server: LSPServer.Handle }) }), 3000, - ) - .catch(() => {}) - .finally(() => { + ), + ).pipe( + Effect.catch(() => Effect.void), + Effect.ensuring( + Effect.sync(() => { if (debounceTimer) clearTimeout(debounceTimer) unsub?.() - }) - }, - async shutdown() { - l.info("shutting down") - connection.end() - connection.dispose() - await Process.stop(input.server.process) - l.info("shutdown") - }, - } + }), + ), + ) + }) + + const shutdown = Effect.fn("LSPClient.shutdown")(function* () { + l.info("shutting down") + connection.end() + connection.dispose() + yield* Effect.promise(() => Process.stop(input.server.process)).pipe(Effect.orDie) + l.info("shutdown") + }) l.info("initialized") - return result -} + return { + root: input.root, + get serverID() { + return input.serverID + }, + get connection() { + return connection + }, + notify: { + open, + }, + get diagnostics() { + return diagnostics + }, + waitForDiagnostics, + shutdown, + } satisfies Info +}) diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 43c830987010..08a0efb40e33 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -134,7 +134,7 @@ interface State { clients: LSPClient.Info[] servers: Record broken: Set - spawning: Map> + spawning: Map> } export interface Interface { @@ -170,7 +170,7 @@ export const layer = Layer.effect( if (!cfg.lsp) { log.info("all LSPs are disabled") } else { - for (const server of Object.values(LSPServer)) { + for (const server of Object.values(LSPServer.Builtins)) { servers[server.id] = server } @@ -187,15 +187,16 @@ export const layer = Layer.effect( servers[name] = { ...existing, id: name, - root: existing?.root ?? (async () => Instance.directory), + root: existing?.root ?? (() => Effect.succeed(Instance.directory)), extensions: item.extensions ?? existing?.extensions ?? [], - spawn: async (root) => ({ - process: lspspawn(item.command[0], item.command.slice(1), { - cwd: root, - env: { ...process.env, ...item.env }, - }), - initialization: item.initialization, - }), + spawn: (root) => + Effect.sync(() => ({ + process: lspspawn(item.command[0], item.command.slice(1), { + cwd: root, + env: { ...process.env, ...item.env }, + }), + initialization: item.initialization, + })), } } } @@ -215,110 +216,121 @@ export const layer = Layer.effect( } yield* Effect.addFinalizer(() => - Effect.promise(async () => { - await Promise.all(s.clients.map((client) => client.shutdown())) - }), + Effect.forEach(s.clients, (client) => client.shutdown(), { concurrency: "unbounded", discard: true }), ) return s }), ) - const getClients = Effect.fnUntraced(function* (file: string) { - if (!Instance.containsPath(file)) return [] as LSPClient.Info[] - const s = yield* InstanceState.get(state) - return yield* Effect.promise(async () => { - const extension = path.parse(file).ext || file - const result: LSPClient.Info[] = [] - - async function schedule(server: LSPServer.Info, root: string, key: string) { - const handle = await server - .spawn(root) - .then((value) => { - if (!value) s.broken.add(key) - return value - }) - .catch((err) => { - s.broken.add(key) - log.error(`Failed to spawn LSP server ${server.id}`, { error: err }) - return undefined - }) - - if (!handle) return undefined - log.info("spawned lsp server", { serverID: server.id, root }) - - const client = await LSPClient.create({ - serverID: server.id, - server: handle, - root, - }).catch(async (err) => { + const request = Effect.fnUntraced(function* ( + client: LSPClient.Info, + method: string, + params: unknown, + fallback: A, + ) { + return yield* (Effect.tryPromise(() => client.connection.sendRequest(method, params)).pipe( + Effect.catch(() => Effect.succeed(fallback)), + )) + }) + + const scheduleClient = Effect.fnUntraced(function* (s: State, server: LSPServer.Info, root: string, key: string) { + const handle = yield* (server.spawn(root).pipe( + Effect.catch((error: unknown) => + Effect.sync(() => { s.broken.add(key) - await Process.stop(handle.process) - log.error(`Failed to initialize LSP client ${server.id}`, { error: err }) - return undefined - }) + log.error(`Failed to spawn LSP server ${server.id}`, { error }) + }).pipe(Effect.as(undefined)), + ), + )) + if (!handle) { + s.broken.add(key) + return undefined + } - if (!client) return undefined + log.info("spawned lsp server", { serverID: server.id, root }) - const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) - if (existing) { - await Process.stop(handle.process) - return existing - } + const client = yield* LSPClient.create({ + serverID: server.id, + server: handle, + root, + }).pipe( + Effect.catch((error: unknown) => + Effect.gen(function* () { + s.broken.add(key) + yield* (Effect.promise(() => Process.stop(handle.process)).pipe(Effect.catch(() => Effect.void))) + log.error(`Failed to initialize LSP client ${server.id}`, { error }) + return undefined + }), + ), + ) + if (!client) return undefined - s.clients.push(client) - return client - } + const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) + if (existing) { + yield* (Effect.promise(() => Process.stop(handle.process)).pipe(Effect.catch(() => Effect.void))) + return existing + } - for (const server of Object.values(s.servers)) { - if (server.extensions.length && !server.extensions.includes(extension)) continue + s.clients.push(client) + return client + }) - const root = await server.root(file) - if (!root) continue - if (s.broken.has(root + server.id)) continue + const awaitSpawn = Effect.fnUntraced(function* (s: State, server: LSPServer.Info, root: string, key: string) { + const inflight = s.spawning.get(key) + if (inflight) return yield* inflight - const match = s.clients.find((x) => x.root === root && x.serverID === server.id) - if (match) { - result.push(match) - continue - } + const task = yield* Effect.cached(scheduleClient(s, server, root, key)) + s.spawning.set(key, task) + return yield* task.pipe( + Effect.ensuring( + Effect.sync(() => { + if (s.spawning.get(key) === task) s.spawning.delete(key) + }), + ), + ) + }) - const inflight = s.spawning.get(root + server.id) - if (inflight) { - const client = await inflight - if (!client) continue - result.push(client) - continue - } + const getClients = Effect.fnUntraced(function* (file: string) { + if (!Instance.containsPath(file)) return [] as LSPClient.Info[] + const s = yield* InstanceState.get(state) + const extension = path.parse(file).ext || file + const result: LSPClient.Info[] = [] - const task = schedule(server, root, root + server.id) - s.spawning.set(root + server.id, task) + for (const server of Object.values(s.servers)) { + if (server.extensions.length && !server.extensions.includes(extension)) continue - task.finally(() => { - if (s.spawning.get(root + server.id) === task) { - s.spawning.delete(root + server.id) - } - }) + const root = yield* server.root(file) + if (!root) continue - const client = await task - if (!client) continue + const key = root + server.id + if (s.broken.has(key)) continue - result.push(client) - Bus.publish(Event.Updated, {}) + const match = s.clients.find((x) => x.root === root && x.serverID === server.id) + if (match) { + result.push(match) + continue } - return result - }) + const hadInflight = s.spawning.has(key) + const client = yield* awaitSpawn(s, server, root, key) + if (!client) continue + + result.push(client) + if (!hadInflight) Bus.publish(Event.Updated, {}) + } + + return result }) - const run = Effect.fnUntraced(function* (file: string, fn: (client: LSPClient.Info) => Promise) { + const run = Effect.fnUntraced(function* (file: string, fn: (client: LSPClient.Info) => Effect.Effect) { const clients = yield* getClients(file) - return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x)))) + return yield* Effect.forEach(clients, fn, { concurrency: "unbounded" }) }) - const runAll = Effect.fnUntraced(function* (fn: (client: LSPClient.Info) => Promise) { + const runAll = Effect.fnUntraced(function* (fn: (client: LSPClient.Info) => Effect.Effect) { const s = yield* InstanceState.get(state) - return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x)))) + return yield* Effect.forEach(s.clients, fn, { concurrency: "unbounded" }) }) const init = Effect.fn("LSP.init")(function* () { @@ -341,38 +353,40 @@ export const layer = Layer.effect( const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) { const s = yield* InstanceState.get(state) - return yield* Effect.promise(async () => { - const extension = path.parse(file).ext || file - for (const server of Object.values(s.servers)) { - if (server.extensions.length && !server.extensions.includes(extension)) continue - const root = await server.root(file) - if (!root) continue - if (s.broken.has(root + server.id)) continue - return true - } - return false - }) + const extension = path.parse(file).ext || file + for (const server of Object.values(s.servers)) { + if (server.extensions.length && !server.extensions.includes(extension)) continue + const root = yield* server.root(file) + if (!root) continue + if (s.broken.has(root + server.id)) continue + return true + } + return false }) const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) { log.info("touching file", { file: input }) const clients = yield* getClients(input) - yield* Effect.promise(() => + yield* Effect.tryPromise(() => Promise.all( clients.map(async (client) => { - const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() - await client.notify.open({ path: input }) + const wait = waitForDiagnostics ? Effect.runPromise(client.waitForDiagnostics({ path: input })) : Promise.resolve() + await Effect.runPromise(client.notify.open({ path: input })) return wait }), - ).catch((err) => { - log.error("failed to touch file", { err, file: input }) - }), + ), + ).pipe( + Effect.catch((err: unknown) => + Effect.sync(() => { + log.error("failed to touch file", { err, file: input }) + }), + ), ) }) const diagnostics = Effect.fn("LSP.diagnostics")(function* () { const results: Record = {} - const all = yield* runAll(async (client) => client.diagnostics) + const all = yield* runAll((client) => Effect.succeed(client.diagnostics)) for (const result of all) { for (const [p, diags] of result.entries()) { const arr = results[p] || [] @@ -385,78 +399,65 @@ export const layer = Layer.effect( const hover = Effect.fn("LSP.hover")(function* (input: LocInput) { return yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/hover", { + request(client, "textDocument/hover", { textDocument: { uri: pathToFileURL(input.file).href }, position: { line: input.line, character: input.character }, - }) - .catch(() => null), + }, null), ) }) const definition = Effect.fn("LSP.definition")(function* (input: LocInput) { const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/definition", { + request(client, "textDocument/definition", { textDocument: { uri: pathToFileURL(input.file).href }, position: { line: input.line, character: input.character }, - }) - .catch(() => null), + }, null), ) return results.flat().filter(Boolean) }) const references = Effect.fn("LSP.references")(function* (input: LocInput) { const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/references", { + request(client, "textDocument/references", { textDocument: { uri: pathToFileURL(input.file).href }, position: { line: input.line, character: input.character }, context: { includeDeclaration: true }, - }) - .catch(() => []), + }, [] as any[]), ) return results.flat().filter(Boolean) }) const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) { const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/implementation", { + request(client, "textDocument/implementation", { textDocument: { uri: pathToFileURL(input.file).href }, position: { line: input.line, character: input.character }, - }) - .catch(() => null), + }, null), ) return results.flat().filter(Boolean) }) const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) { const file = fileURLToPath(uri) - const results = yield* run(file, (client) => - client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []), - ) + const results = yield* run(file, (client) => request(client, "textDocument/documentSymbol", { textDocument: { uri } }, [] as any[])) return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean) }) const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) { const results = yield* runAll((client) => - client.connection - .sendRequest("workspace/symbol", { query }) - .then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10)) - .catch(() => [] as Symbol[]), + request(client, "workspace/symbol", { query }, [] as Symbol[]).pipe( + Effect.map((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10)), + ), ) return results.flat() }) const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) { const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/prepareCallHierarchy", { + request(client, "textDocument/prepareCallHierarchy", { textDocument: { uri: pathToFileURL(input.file).href }, position: { line: input.line, character: input.character }, - }) - .catch(() => []), + }, [] as any[]), ) return results.flat().filter(Boolean) }) @@ -465,16 +466,16 @@ export const layer = Layer.effect( input: LocInput, direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls", ) { - const results = yield* run(input.file, async (client) => { - const items = await client.connection - .sendRequest("textDocument/prepareCallHierarchy", { + const results = yield* run(input.file, (client) => + Effect.gen(function* () { + const items = yield* request(client, "textDocument/prepareCallHierarchy", { textDocument: { uri: pathToFileURL(input.file).href }, position: { line: input.line, character: input.character }, - }) - .catch(() => [] as unknown[]) - if (!items?.length) return [] - return client.connection.sendRequest(direction, { item: items[0] }).catch(() => []) - }) + }, [] as unknown[]) + if (!items.length) return [] + return yield* request(client, direction, { item: items[0] }, [] as unknown[]) + }), + ) return results.flat().filter(Boolean) }) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 760e8eaba0e4..c00faef3dd3d 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -14,6 +14,7 @@ import { which } from "../util/which" import { Module } from "@opencode-ai/shared/util/module" import { spawn } from "./launch" import { Npm } from "../npm" +import { Effect } from "effect" const log = Log.create({ service: "lsp.server" }) const pathExists = async (p: string) => @@ -29,9 +30,10 @@ export interface Handle { initialization?: Record } -type RootFunction = (file: string) => Promise +type RawRootFunction = (file: string) => Promise +type RootFunction = (file: string) => Effect.Effect -const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => { +const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RawRootFunction => { return async (file) => { if (excludePatterns) { const excludedFiles = Filesystem.up({ @@ -55,15 +57,36 @@ const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): Roo } } +export interface RawInfo { + id: string + extensions: string[] + global?: boolean + root: RawRootFunction + spawn(root: string): Promise +} + export interface Info { id: string extensions: string[] global?: boolean root: RootFunction - spawn(root: string): Promise + spawn(root: string): Effect.Effect } -export const Deno: Info = { +const effectify = (info: RawInfo): Info => ({ + ...info, + root: (file) => Effect.promise(() => info.root(file)), + spawn: (root) => Effect.promise(() => info.spawn(root)), +}) + +const effectifyAll = >(infos: T): { [K in keyof T]: Info } => + Object.fromEntries(Object.entries(infos).map(([key, value]) => [key, effectify(value)])) as { [K in keyof T]: Info } + +// Temporary migration bridge: `Builtins` exposes Effect-shaped `root` / `spawn` +// while the per-server definitions still use their older Promise bodies. +// Follow-up: convert the individual server definitions in place and delete this wrapper. + +export const Deno: RawInfo = { id: "deno", root: async (file) => { const files = Filesystem.up({ @@ -91,7 +114,7 @@ export const Deno: Info = { }, } -export const Typescript: Info = { +export const Typescript: RawInfo = { id: "typescript", root: NearestRoot( ["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"], @@ -121,7 +144,7 @@ export const Typescript: Info = { }, } -export const Vue: Info = { +export const Vue: RawInfo = { id: "vue", extensions: [".vue"], root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), @@ -150,7 +173,7 @@ export const Vue: Info = { }, } -export const ESLint: Info = { +export const ESLint: RawInfo = { id: "eslint", root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], @@ -207,7 +230,7 @@ export const ESLint: Info = { }, } -export const Oxlint: Info = { +export const Oxlint: RawInfo = { id: "oxlint", root: NearestRoot([ ".oxlintrc.json", @@ -280,7 +303,7 @@ export const Oxlint: Info = { }, } -export const Biome: Info = { +export const Biome: RawInfo = { id: "biome", root: NearestRoot([ "biome.json", @@ -342,7 +365,7 @@ export const Biome: Info = { }, } -export const Gopls: Info = { +export const Gopls: RawInfo = { id: "gopls", root: async (file) => { const work = await NearestRoot(["go.work"])(file) @@ -381,7 +404,7 @@ export const Gopls: Info = { }, } -export const Rubocop: Info = { +export const Rubocop: RawInfo = { id: "ruby-lsp", root: NearestRoot(["Gemfile"]), extensions: [".rb", ".rake", ".gemspec", ".ru"], @@ -419,7 +442,7 @@ export const Rubocop: Info = { }, } -export const Ty: Info = { +export const Ty: RawInfo = { id: "ty", extensions: [".py", ".pyi"], root: NearestRoot([ @@ -481,7 +504,7 @@ export const Ty: Info = { }, } -export const Pyright: Info = { +export const Pyright: RawInfo = { id: "pyright", extensions: [".py", ".pyi"], root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]), @@ -525,7 +548,7 @@ export const Pyright: Info = { }, } -export const ElixirLS: Info = { +export const ElixirLS: RawInfo = { id: "elixir-ls", extensions: [".ex", ".exs"], root: NearestRoot(["mix.exs", "mix.lock"]), @@ -588,7 +611,7 @@ export const ElixirLS: Info = { }, } -export const Zls: Info = { +export const Zls: RawInfo = { id: "zls", extensions: [".zig", ".zon"], root: NearestRoot(["build.zig"]), @@ -700,7 +723,7 @@ export const Zls: Info = { }, } -export const CSharp: Info = { +export const CSharp: RawInfo = { id: "csharp", root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), extensions: [".cs"], @@ -737,7 +760,7 @@ export const CSharp: Info = { }, } -export const FSharp: Info = { +export const FSharp: RawInfo = { id: "fsharp", root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]), extensions: [".fs", ".fsi", ".fsx", ".fsscript"], @@ -774,7 +797,7 @@ export const FSharp: Info = { }, } -export const SourceKit: Info = { +export const SourceKit: RawInfo = { id: "sourcekit-lsp", extensions: [".swift", ".objc", "objcpp"], root: NearestRoot(["Package.swift", "*.xcodeproj", "*.xcworkspace"]), @@ -808,7 +831,7 @@ export const SourceKit: Info = { }, } -export const RustAnalyzer: Info = { +export const RustAnalyzer: RawInfo = { id: "rust", root: async (root) => { const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root) @@ -854,7 +877,7 @@ export const RustAnalyzer: Info = { }, } -export const Clangd: Info = { +export const Clangd: RawInfo = { id: "clangd", root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]), extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], @@ -1000,7 +1023,7 @@ export const Clangd: Info = { }, } -export const Svelte: Info = { +export const Svelte: RawInfo = { id: "svelte", extensions: [".svelte"], root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), @@ -1027,7 +1050,7 @@ export const Svelte: Info = { }, } -export const Astro: Info = { +export const Astro: RawInfo = { id: "astro", extensions: [".astro"], root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), @@ -1065,7 +1088,7 @@ export const Astro: Info = { }, } -export const JDTLS: Info = { +export const JDTLS: RawInfo = { id: "jdtls", root: async (file) => { // Without exclusions, NearestRoot defaults to instance directory so we can't @@ -1186,7 +1209,7 @@ export const JDTLS: Info = { }, } -export const KotlinLS: Info = { +export const KotlinLS: RawInfo = { id: "kotlin-ls", extensions: [".kt", ".kts"], root: async (file) => { @@ -1285,7 +1308,7 @@ export const KotlinLS: Info = { }, } -export const YamlLS: Info = { +export const YamlLS: RawInfo = { id: "yaml-ls", extensions: [".yaml", ".yml"], root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), @@ -1311,7 +1334,7 @@ export const YamlLS: Info = { }, } -export const LuaLS: Info = { +export const LuaLS: RawInfo = { id: "lua-ls", root: NearestRoot([ ".luarc.json", @@ -1452,7 +1475,7 @@ export const LuaLS: Info = { }, } -export const PHPIntelephense: Info = { +export const PHPIntelephense: RawInfo = { id: "php intelephense", extensions: [".php"], root: NearestRoot(["composer.json", "composer.lock", ".php-version"]), @@ -1483,7 +1506,7 @@ export const PHPIntelephense: Info = { }, } -export const Prisma: Info = { +export const Prisma: RawInfo = { id: "prisma", extensions: [".prisma"], root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]), @@ -1501,7 +1524,7 @@ export const Prisma: Info = { }, } -export const Dart: Info = { +export const Dart: RawInfo = { id: "dart", extensions: [".dart"], root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]), @@ -1519,7 +1542,7 @@ export const Dart: Info = { }, } -export const Ocaml: Info = { +export const Ocaml: RawInfo = { id: "ocaml-lsp", extensions: [".ml", ".mli"], root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]), @@ -1536,7 +1559,7 @@ export const Ocaml: Info = { } }, } -export const BashLS: Info = { +export const BashLS: RawInfo = { id: "bash", extensions: [".sh", ".bash", ".zsh", ".ksh"], root: async () => Instance.directory, @@ -1562,7 +1585,7 @@ export const BashLS: Info = { }, } -export const TerraformLS: Info = { +export const TerraformLS: RawInfo = { id: "terraform", extensions: [".tf", ".tfvars"], root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]), @@ -1643,7 +1666,7 @@ export const TerraformLS: Info = { }, } -export const TexLab: Info = { +export const TexLab: RawInfo = { id: "texlab", extensions: [".tex", ".bib"], root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), @@ -1731,7 +1754,7 @@ export const TexLab: Info = { }, } -export const DockerfileLS: Info = { +export const DockerfileLS: RawInfo = { id: "dockerfile", extensions: [".dockerfile", "Dockerfile"], root: async () => Instance.directory, @@ -1757,7 +1780,7 @@ export const DockerfileLS: Info = { }, } -export const Gleam: Info = { +export const Gleam: RawInfo = { id: "gleam", extensions: [".gleam"], root: NearestRoot(["gleam.toml"]), @@ -1775,7 +1798,7 @@ export const Gleam: Info = { }, } -export const Clojure: Info = { +export const Clojure: RawInfo = { id: "clojure-lsp", extensions: [".clj", ".cljs", ".cljc", ".edn"], root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]), @@ -1796,7 +1819,7 @@ export const Clojure: Info = { }, } -export const Nixd: Info = { +export const Nixd: RawInfo = { id: "nixd", extensions: [".nix"], root: async (file) => { @@ -1827,7 +1850,7 @@ export const Nixd: Info = { }, } -export const Tinymist: Info = { +export const Tinymist: RawInfo = { id: "tinymist", extensions: [".typ", ".typc"], root: NearestRoot(["typst.toml"]), @@ -1919,7 +1942,7 @@ export const Tinymist: Info = { }, } -export const HLS: Info = { +export const HLS: RawInfo = { id: "haskell-language-server", extensions: [".hs", ".lhs"], root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]), @@ -1937,7 +1960,7 @@ export const HLS: Info = { }, } -export const JuliaLS: Info = { +export const JuliaLS: RawInfo = { id: "julials", extensions: [".jl"], root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]), @@ -1954,3 +1977,43 @@ export const JuliaLS: Info = { } }, } + +export const Builtins = effectifyAll({ + Deno, + Typescript, + Vue, + ESLint, + Oxlint, + Biome, + Gopls, + Rubocop, + Ty, + Pyright, + ElixirLS, + Zls, + CSharp, + FSharp, + SourceKit, + RustAnalyzer, + Clangd, + Svelte, + Astro, + JDTLS, + KotlinLS, + YamlLS, + LuaLS, + PHPIntelephense, + Prisma, + Dart, + Ocaml, + BashLS, + TerraformLS, + TexLab, + DockerfileLS, + Gleam, + Clojure, + Nixd, + Tinymist, + HLS, + JuliaLS, +}) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index f124fddf9581..1bb2f1ff9a57 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test, beforeEach } from "bun:test" import path from "path" +import { Effect } from "effect" import { LSPClient } from "../../src/lsp" import { LSPServer } from "../../src/lsp" import { Instance } from "../../src/project/instance" @@ -27,11 +28,13 @@ describe("LSPClient interop", () => { const client = await Instance.provide({ directory: process.cwd(), fn: () => - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), + Effect.runPromise( + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }), + ), }) await client.connection.sendNotification("test/trigger", { @@ -42,7 +45,7 @@ describe("LSPClient interop", () => { expect(client.connection).toBeDefined() - await client.shutdown() + await Effect.runPromise(client.shutdown()) }) test("handles client/registerCapability request", async () => { @@ -51,11 +54,13 @@ describe("LSPClient interop", () => { const client = await Instance.provide({ directory: process.cwd(), fn: () => - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), + Effect.runPromise( + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }), + ), }) await client.connection.sendNotification("test/trigger", { @@ -66,7 +71,7 @@ describe("LSPClient interop", () => { expect(client.connection).toBeDefined() - await client.shutdown() + await Effect.runPromise(client.shutdown()) }) test("handles client/unregisterCapability request", async () => { @@ -75,11 +80,13 @@ describe("LSPClient interop", () => { const client = await Instance.provide({ directory: process.cwd(), fn: () => - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), + Effect.runPromise( + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }), + ), }) await client.connection.sendNotification("test/trigger", { @@ -90,6 +97,6 @@ describe("LSPClient interop", () => { expect(client.connection).toBeDefined() - await client.shutdown() + await Effect.runPromise(client.shutdown()) }) }) From bd5b892234d58e2f7ca2315cba0d0b1db3ec2096 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 20:51:48 -0400 Subject: [PATCH 10/12] refactor(lsp): simplify effect timeout flow --- packages/opencode/src/lsp/client.ts | 105 ++++++++++++---------- packages/opencode/src/lsp/lsp.ts | 20 +++-- packages/opencode/src/lsp/server.ts | 9 +- packages/opencode/test/lsp/client.test.ts | 56 +++++------- 4 files changed, 92 insertions(+), 98 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index bfa60f9583f6..177f3cb119a2 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -11,7 +11,6 @@ import { LANGUAGE_EXTENSIONS } from "./language" import z from "zod" import type * as LSPServer from "./server" import { NamedError } from "@opencode-ai/shared/util/error" -import { withTimeout } from "../util/timeout" import { Instance } from "../project/instance" import { Filesystem } from "../util" @@ -93,54 +92,65 @@ export const create = Effect.fn("LSPClient.create")(function* (input: { connection.listen() l.info("sending initialize") - yield* Effect.tryPromise({ - try: () => - withTimeout( - connection.sendRequest("initialize", { - rootUri: pathToFileURL(input.root).href, - processId: input.server.process.pid, - workspaceFolders: [ - { - name: "workspace", - uri: pathToFileURL(input.root).href, - }, - ], - initializationOptions: { - ...input.server.initialization, + yield* Effect.tryPromise(() => + connection.sendRequest("initialize", { + rootUri: pathToFileURL(input.root).href, + processId: input.server.process.pid, + workspaceFolders: [ + { + name: "workspace", + uri: pathToFileURL(input.root).href, + }, + ], + initializationOptions: { + ...input.server.initialization, + }, + capabilities: { + window: { + workDoneProgress: true, + }, + workspace: { + configuration: true, + didChangeWatchedFiles: { + dynamicRegistration: true, }, - capabilities: { - window: { - workDoneProgress: true, - }, - workspace: { - configuration: true, - didChangeWatchedFiles: { - dynamicRegistration: true, - }, - }, - textDocument: { - synchronization: { - didOpen: true, - didChange: true, - }, - publishDiagnostics: { - versionSupport: true, - }, - }, + }, + textDocument: { + synchronization: { + didOpen: true, + didChange: true, + }, + publishDiagnostics: { + versionSupport: true, }, - }), - 45_000, - ), - catch: (error) => { - l.error("initialize error", { error }) - return new InitializeError( - { serverID: input.serverID }, - { - cause: error, }, + }, + }), + ).pipe( + Effect.timeoutOrElse({ + duration: 45_000, + orElse: () => + Effect.fail( + new InitializeError( + { serverID: input.serverID }, + { cause: new Error("LSP initialize timed out after 45 seconds") }, + ), + ), + }), + Effect.catch((error) => { + l.error("initialize error", { error }) + return Effect.fail( + error instanceof InitializeError + ? error + : new InitializeError( + { serverID: input.serverID }, + { + cause: error, + }, + ), ) - }, - }) + }), + ) yield* Effect.tryPromise(() => connection.sendNotification("initialized", {})) @@ -227,7 +237,6 @@ export const create = Effect.fn("LSPClient.create")(function* (input: { let unsub: (() => void) | undefined let debounceTimer: ReturnType | undefined yield* Effect.promise(() => - withTimeout( new Promise((resolve) => { unsub = Bus.subscribe(Event.Diagnostics, (event) => { if (event.properties.path === normalizedPath && event.properties.serverID === input.serverID) { @@ -240,10 +249,8 @@ export const create = Effect.fn("LSPClient.create")(function* (input: { } }) }), - 3000, - ), ).pipe( - Effect.catch(() => Effect.void), + Effect.timeoutOrElse({ duration: 3000, orElse: () => Effect.void }), Effect.ensuring( Effect.sync(() => { if (debounceTimer) clearTimeout(debounceTimer) diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 08a0efb40e33..34d7a412b13d 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -11,7 +11,7 @@ import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" import { Process } from "../util" import { spawn as lspspawn } from "./launch" -import { Effect, Layer, Context } from "effect" +import { Effect, Fiber, Layer, Context, Scope } from "effect" import { InstanceState } from "@/effect" const log = Log.create({ service: "lsp" }) @@ -160,6 +160,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service + const scope = yield* Scope.Scope const state = yield* InstanceState.make( Effect.fn("LSP.state")(function* () { @@ -367,14 +368,17 @@ export const layer = Layer.effect( const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) { log.info("touching file", { file: input }) const clients = yield* getClients(input) - yield* Effect.tryPromise(() => - Promise.all( - clients.map(async (client) => { - const wait = waitForDiagnostics ? Effect.runPromise(client.waitForDiagnostics({ path: input })) : Promise.resolve() - await Effect.runPromise(client.notify.open({ path: input })) - return wait + yield* Effect.forEach( + clients, + (client) => + Effect.gen(function* () { + const waiting = waitForDiagnostics + ? yield* client.waitForDiagnostics({ path: input }).pipe(Effect.forkIn(scope)) + : undefined + yield* client.notify.open({ path: input }) + if (waiting) yield* Fiber.join(waiting) }), - ), + { concurrency: "unbounded", discard: true }, ).pipe( Effect.catch((err: unknown) => Effect.sync(() => { diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index c00faef3dd3d..fd2ecdf3fc77 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -17,11 +17,6 @@ import { Npm } from "../npm" import { Effect } from "effect" const log = Log.create({ service: "lsp.server" }) -const pathExists = async (p: string) => - fs - .stat(p) - .then(() => true) - .catch(() => false) const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true }) const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true }) @@ -1131,7 +1126,7 @@ export const JDTLS: RawInfo = { } const distPath = path.join(Global.Path.bin, "jdtls") const launcherDir = path.join(distPath, "plugins") - const installed = await pathExists(launcherDir) + const installed = await Filesystem.exists(launcherDir) if (!installed) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("Downloading JDTLS LSP server.") @@ -1163,7 +1158,7 @@ export const JDTLS: RawInfo = { .find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item)) ?.trim() ?? "" const launcherJar = path.join(launcherDir, jarFileName) - if (!(await pathExists(launcherJar))) { + if (!(await Filesystem.exists(launcherJar))) { log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`) return } diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index 1bb2f1ff9a57..d496b85842c1 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -3,8 +3,8 @@ import path from "path" import { Effect } from "effect" import { LSPClient } from "../../src/lsp" import { LSPServer } from "../../src/lsp" -import { Instance } from "../../src/project/instance" import { Log } from "../../src/util" +import { provideInstance } from "../fixture/fixture" // Minimal fake LSP server that speaks JSON-RPC over stdio function spawnFakeServer() { @@ -25,17 +25,13 @@ describe("LSPClient interop", () => { test("handles workspace/workspaceFolders request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ - directory: process.cwd(), - fn: () => - Effect.runPromise( - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), - ), - }) + const client = await Effect.runPromise( + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }).pipe(provideInstance(process.cwd())), + ) await client.connection.sendNotification("test/trigger", { method: "workspace/workspaceFolders", @@ -51,17 +47,13 @@ describe("LSPClient interop", () => { test("handles client/registerCapability request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ - directory: process.cwd(), - fn: () => - Effect.runPromise( - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), - ), - }) + const client = await Effect.runPromise( + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }).pipe(provideInstance(process.cwd())), + ) await client.connection.sendNotification("test/trigger", { method: "client/registerCapability", @@ -77,17 +69,13 @@ describe("LSPClient interop", () => { test("handles client/unregisterCapability request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ - directory: process.cwd(), - fn: () => - Effect.runPromise( - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), - ), - }) + const client = await Effect.runPromise( + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }).pipe(provideInstance(process.cwd())), + ) await client.connection.sendNotification("test/trigger", { method: "client/unregisterCapability", From 7b38fb1e62efc1728c776ea803d7ca463246234a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 21:05:39 -0400 Subject: [PATCH 11/12] refactor(lsp): use beta effect timeout helpers --- packages/opencode/src/lsp/client.ts | 66 +++++++++-------------------- 1 file changed, 19 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 177f3cb119a2..6c1f3a661125 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -127,29 +127,9 @@ export const create = Effect.fn("LSPClient.create")(function* (input: { }, }), ).pipe( - Effect.timeoutOrElse({ - duration: 45_000, - orElse: () => - Effect.fail( - new InitializeError( - { serverID: input.serverID }, - { cause: new Error("LSP initialize timed out after 45 seconds") }, - ), - ), - }), - Effect.catch((error) => { - l.error("initialize error", { error }) - return Effect.fail( - error instanceof InitializeError - ? error - : new InitializeError( - { serverID: input.serverID }, - { - cause: error, - }, - ), - ) - }), + Effect.timeout(45_000), + Effect.mapError((cause) => new InitializeError({ serverID: input.serverID }, { cause })), + Effect.tapError((error) => Effect.sync(() => l.error("initialize error", { error }))), ) yield* Effect.tryPromise(() => connection.sendNotification("initialized", {})) @@ -234,30 +214,22 @@ export const create = Effect.fn("LSPClient.create")(function* (input: { path.isAbsolute(next.path) ? next.path : path.resolve(Instance.directory, next.path), ) log.info("waiting for diagnostics", { path: normalizedPath }) - let unsub: (() => void) | undefined - let debounceTimer: ReturnType | undefined - yield* Effect.promise(() => - new Promise((resolve) => { - unsub = Bus.subscribe(Event.Diagnostics, (event) => { - if (event.properties.path === normalizedPath && event.properties.serverID === input.serverID) { - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(() => { - log.info("got diagnostics", { path: normalizedPath }) - unsub?.() - resolve() - }, DIAGNOSTICS_DEBOUNCE_MS) - } - }) - }), - ).pipe( - Effect.timeoutOrElse({ duration: 3000, orElse: () => Effect.void }), - Effect.ensuring( - Effect.sync(() => { - if (debounceTimer) clearTimeout(debounceTimer) - unsub?.() - }), - ), - ) + yield* Effect.callback((resume) => { + let debounceTimer: ReturnType | undefined + const unsub = Bus.subscribe(Event.Diagnostics, (event) => { + if (event.properties.path !== normalizedPath || event.properties.serverID !== input.serverID) return + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + log.info("got diagnostics", { path: normalizedPath }) + resume(Effect.void) + }, DIAGNOSTICS_DEBOUNCE_MS) + }) + + return Effect.sync(() => { + if (debounceTimer) clearTimeout(debounceTimer) + unsub() + }) + }).pipe(Effect.timeoutOption(3000), Effect.asVoid) }) const shutdown = Effect.fn("LSPClient.shutdown")(function* () { From 83f83ef29bffc96a78e93264803ed575fd4b8599 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 21:20:37 -0400 Subject: [PATCH 12/12] test(lsp): cover diagnostics wait and spawn dedupe --- packages/opencode/test/lsp/client.test.ts | 71 ++++++++++++-------- packages/opencode/test/lsp/lifecycle.test.ts | 31 ++++++++- 2 files changed, 74 insertions(+), 28 deletions(-) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index d496b85842c1..d1facbf5a156 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, beforeEach } from "bun:test" import path from "path" import { Effect } from "effect" +import { Bus } from "../../src/bus" import { LSPClient } from "../../src/lsp" import { LSPServer } from "../../src/lsp" import { Log } from "../../src/util" @@ -17,21 +18,27 @@ function spawnFakeServer() { } } +async function createClient() { + const handle = spawnFakeServer() as any + const cwd = process.cwd() + const client = await Effect.runPromise( + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: cwd, + }).pipe(provideInstance(cwd)), + ) + + return { client, cwd } +} + describe("LSPClient interop", () => { beforeEach(async () => { await Log.init({ print: true }) }) test("handles workspace/workspaceFolders request", async () => { - const handle = spawnFakeServer() as any - - const client = await Effect.runPromise( - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }).pipe(provideInstance(process.cwd())), - ) + const { client } = await createClient() await client.connection.sendNotification("test/trigger", { method: "workspace/workspaceFolders", @@ -45,15 +52,7 @@ describe("LSPClient interop", () => { }) test("handles client/registerCapability request", async () => { - const handle = spawnFakeServer() as any - - const client = await Effect.runPromise( - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }).pipe(provideInstance(process.cwd())), - ) + const { client } = await createClient() await client.connection.sendNotification("test/trigger", { method: "client/registerCapability", @@ -67,15 +66,7 @@ describe("LSPClient interop", () => { }) test("handles client/unregisterCapability request", async () => { - const handle = spawnFakeServer() as any - - const client = await Effect.runPromise( - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }).pipe(provideInstance(process.cwd())), - ) + const { client } = await createClient() await client.connection.sendNotification("test/trigger", { method: "client/unregisterCapability", @@ -87,4 +78,30 @@ describe("LSPClient interop", () => { await Effect.runPromise(client.shutdown()) }) + + test("waitForDiagnostics() resolves when a matching diagnostic event is published", async () => { + const { client, cwd } = await createClient() + const file = path.join(cwd, "fixture.ts") + + const waiting = Effect.runPromise(client.waitForDiagnostics({ path: file }).pipe(provideInstance(cwd))) + + await Effect.runPromise(Effect.sleep(20)) + await Effect.runPromise(Effect.promise(() => Bus.publish(LSPClient.Event.Diagnostics, { path: file, serverID: "fake" })).pipe(provideInstance(cwd))) + await waiting + + await Effect.runPromise(client.shutdown()) + }) + + test("waitForDiagnostics() times out without throwing when no event arrives", async () => { + const { client, cwd } = await createClient() + const started = Date.now() + + await Effect.runPromise(client.waitForDiagnostics({ path: path.join(cwd, "never.ts") }).pipe(provideInstance(cwd))) + + const elapsed = Date.now() - started + expect(elapsed).toBeGreaterThanOrEqual(2900) + expect(elapsed).toBeLessThan(5000) + + await Effect.runPromise(client.shutdown()) + }) }) diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index 13f21c93cc7e..ca10f208fc0d 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import path from "path" -import { Effect, Layer } from "effect" +import { Effect, Fiber, Layer, Scope } from "effect" import { LSP } from "../../src/lsp" import { LSPServer } from "../../src/lsp" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" @@ -153,6 +153,35 @@ describe("LSP service lifecycle", () => { ), ), ) + + it.live("touchFile() dedupes concurrent spawn attempts for the same file", () => + provideTmpdirInstance( + (dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const gate = Promise.withResolvers() + const scope = yield* Scope.Scope + const file = path.join(dir, "src", "inside.ts") + + spawnSpy.mockImplementation(async () => { + await gate.promise + return undefined + }) + + const fiber = yield* Effect.all([lsp.touchFile(file, false), lsp.touchFile(file, false)], { + concurrency: "unbounded", + }).pipe(Effect.forkIn(scope)) + + yield* Effect.sleep(20) + expect(spawnSpy).toHaveBeenCalledTimes(1) + + gate.resolve() + yield* Fiber.join(fiber) + }), + ), + { config: { lsp: true } }, + ), + ) }) describe("LSP.Diagnostic", () => {