From 59e6967b8fed74f966d13186ce78398c58163e4b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 19:24:58 -0400 Subject: [PATCH 01/32] Generate config schema from Effect Schema (#26939) --- packages/opencode/script/schema.ts | 78 ++++++++---------------------- 1 file changed, 19 insertions(+), 59 deletions(-) diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index 9052645d54..b335c62df4 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -1,64 +1,11 @@ #!/usr/bin/env bun -import { z } from "zod" import { Config } from "@/config/config" -import { zodObject } from "@opencode-ai/core/effect-zod" -import { TuiJsonSchema } from "../src/cli/cmd/tui/config/tui-json-schema" import { Schema } from "effect" +import { TuiJsonSchema } from "../src/cli/cmd/tui/config/tui-json-schema" type JsonSchema = Record - -function generate(schema: z.ZodType) { - const result = z.toJSONSchema(schema, { - io: "input", // Generate input shape (treats optional().default() as not required) - /** - * We'll use the `default` values of the field as the only value in `examples`. - * This will ensure no docs are needed to be read, as the configuration is - * self-documenting. - * - * See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5 - */ - override(ctx) { - const schema = ctx.jsonSchema - - // Preserve strictness: set additionalProperties: false for objects - if ( - schema && - typeof schema === "object" && - schema.type === "object" && - schema.additionalProperties === undefined - ) { - schema.additionalProperties = false - } - - // Add examples and default descriptions for string fields with defaults - if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) { - if (!schema.examples) { - schema.examples = [schema.default] - } - - schema.description = [schema.description || "", `default: \`${formatDefault(schema.default)}\``] - .filter(Boolean) - .join("\n\n") - .trim() - } - }, - }) as Record & { - allowComments?: boolean - allowTrailingCommas?: boolean - } - - // used for json lsps since config supports jsonc - result.allowComments = true - result.allowTrailingCommas = true - - return result -} - -function formatDefault(value: unknown) { - if (typeof value !== "object" || value === null) return String(value) - return JSON.stringify(value) -} +const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model" function generateEffect(schema: Schema.Top) { const document = Schema.toJsonSchemaDocument(schema) @@ -68,9 +15,11 @@ function generateEffect(schema: Schema.Top) { $defs: document.definitions, }) if (!isRecord(normalized)) throw new Error("schema generator produced a non-object schema") - normalized.allowComments = true - normalized.allowTrailingCommas = true - return normalized + const restored = restoreModelRefs(normalized) + if (!isRecord(restored)) throw new Error("schema generator produced a non-object schema") + restored.allowComments = true + restored.allowTrailingCommas = true + return restored } function normalize(value: unknown): unknown { @@ -100,6 +49,17 @@ function normalize(value: unknown): unknown { return schema } +function restoreModelRefs(value: unknown, key?: string): unknown { + if (Array.isArray(value)) return value.map((item) => restoreModelRefs(item)) + if (!isRecord(value)) return value + + const schema = Object.fromEntries(Object.entries(value).map(([name, item]) => [name, restoreModelRefs(item, name)])) + if ((key === "model" || key === "small_model") && schema.type === "string") { + return { ...schema, $ref: MODEL_REF } + } + return schema +} + function isRecord(value: unknown): value is JsonSchema { return typeof value === "object" && value !== null && !Array.isArray(value) } @@ -108,7 +68,7 @@ const configFile = process.argv[2] const tuiFile = process.argv[3] console.log(configFile) -await Bun.write(configFile, JSON.stringify(generate(zodObject(Config.Info).strict().meta({ ref: "Config" })), null, 2)) +await Bun.write(configFile, JSON.stringify(generateEffect(Config.Info), null, 2)) if (tuiFile) { console.log(tuiFile) From fdeb2748e18255e9c2d5bd7fd3421aa1206d59e5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 19:38:54 -0400 Subject: [PATCH 02/32] test(agent): isolate plugin agent regression (#26948) --- .../agent/plugin-agent-regression.test.ts | 28 ++++++++++++++++++- packages/opencode/test/fake/account.ts | 9 ++++++ packages/opencode/test/fake/auth.ts | 8 ++++++ packages/opencode/test/fake/npm.ts | 8 ++++++ packages/opencode/test/fake/skill.ts | 8 ++++++ 5 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/fake/account.ts create mode 100644 packages/opencode/test/fake/auth.ts create mode 100644 packages/opencode/test/fake/npm.ts create mode 100644 packages/opencode/test/fake/skill.ts diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index dff972d100..e2dd8a5f7c 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,9 +1,18 @@ import { expect } from "bun:test" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Layer } from "effect" import path from "path" import { pathToFileURL } from "url" import { Agent } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config/config" +import { Env } from "../../src/env" import { Plugin } from "../../src/plugin" +import { AccountTest } from "../fake/account" +import { AuthTest } from "../fake/auth" +import { NpmTest } from "../fake/npm" +import { ProviderTest } from "../fake/provider" +import { SkillTest } from "../fake/skill" import { testEffect } from "../lib/effect" import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" @@ -12,7 +21,24 @@ import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" // to verify plugin → config hook → Agent.list. const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href -const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Plugin.defaultLayer)) +const provider = ProviderTest.fake() +const configLayer = Config.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(AuthTest.empty), + Layer.provide(AccountTest.empty), + Layer.provide(NpmTest.noop), +) +const pluginLayer = Plugin.layer.pipe(Layer.provide(Bus.layer), Layer.provide(configLayer)) +const agentLayer = Agent.layer.pipe( + Layer.provide(configLayer), + Layer.provide(AuthTest.empty), + Layer.provide(SkillTest.empty), + Layer.provide(provider.layer), + Layer.provide(pluginLayer), +) + +const it = testEffect(Layer.mergeAll(agentLayer, pluginLayer)) it.instance( "plugin-registered agents appear in Agent.list", diff --git a/packages/opencode/test/fake/account.ts b/packages/opencode/test/fake/account.ts new file mode 100644 index 0000000000..aeaa0735bc --- /dev/null +++ b/packages/opencode/test/fake/account.ts @@ -0,0 +1,9 @@ +import { Effect, Layer, Option } from "effect" +import { Account } from "../../src/account/account" + +export const empty = Layer.mock(Account.Service)({ + active: () => Effect.succeed(Option.none()), + activeOrg: () => Effect.succeed(Option.none()), +}) + +export * as AccountTest from "./account" diff --git a/packages/opencode/test/fake/auth.ts b/packages/opencode/test/fake/auth.ts new file mode 100644 index 0000000000..c82babb6a2 --- /dev/null +++ b/packages/opencode/test/fake/auth.ts @@ -0,0 +1,8 @@ +import { Effect, Layer } from "effect" +import { Auth } from "../../src/auth" + +export const empty = Layer.mock(Auth.Service)({ + all: () => Effect.succeed({}), +}) + +export * as AuthTest from "./auth" diff --git a/packages/opencode/test/fake/npm.ts b/packages/opencode/test/fake/npm.ts new file mode 100644 index 0000000000..57efe83a0b --- /dev/null +++ b/packages/opencode/test/fake/npm.ts @@ -0,0 +1,8 @@ +import { Npm } from "@opencode-ai/core/npm" +import { Effect, Layer } from "effect" + +export const noop = Layer.mock(Npm.Service)({ + install: () => Effect.void, +}) + +export * as NpmTest from "./npm" diff --git a/packages/opencode/test/fake/skill.ts b/packages/opencode/test/fake/skill.ts new file mode 100644 index 0000000000..1a329ace36 --- /dev/null +++ b/packages/opencode/test/fake/skill.ts @@ -0,0 +1,8 @@ +import { Effect, Layer } from "effect" +import { Skill } from "../../src/skill" + +export const empty = Layer.mock(Skill.Service)({ + dirs: () => Effect.succeed([]), +}) + +export * as SkillTest from "./skill" From 46edc98f1042ec1e377a318ec1348db3cfe2620b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 19:51:45 -0400 Subject: [PATCH 03/32] Validate TUI config with Effect Schema (#26952) --- packages/opencode/script/schema.ts | 4 +- .../src/cli/cmd/tui/config/keybind.ts | 101 +++++++++++------- .../src/cli/cmd/tui/config/tui-json-schema.ts | 66 ------------ .../src/cli/cmd/tui/config/tui-migrate.ts | 54 +++++----- .../src/cli/cmd/tui/config/tui-schema.ts | 54 +++++----- .../opencode/src/cli/cmd/tui/config/tui.ts | 30 +++--- packages/opencode/src/config/plugin.ts | 6 +- 7 files changed, 139 insertions(+), 176 deletions(-) delete mode 100644 packages/opencode/src/cli/cmd/tui/config/tui-json-schema.ts diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index b335c62df4..b34eaf7f0e 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -2,7 +2,7 @@ import { Config } from "@/config/config" import { Schema } from "effect" -import { TuiJsonSchema } from "../src/cli/cmd/tui/config/tui-json-schema" +import { TuiInfo } from "../src/cli/cmd/tui/config/tui-schema" type JsonSchema = Record const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model" @@ -72,5 +72,5 @@ await Bun.write(configFile, JSON.stringify(generateEffect(Config.Info), null, 2) if (tuiFile) { console.log(tuiFile) - await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiJsonSchema.Info), null, 2)) + await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiInfo), null, 2)) } diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index 5e7fec4018..4623893161 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -3,33 +3,39 @@ export * as TuiKeybind from "./keybind" import type { KeyEvent, Renderable } from "@opentui/core" import type { Binding } from "@opentui/keymap" import type { BindingCommandMap, BindingConfig, BindingDefaults } from "@opentui/keymap/extras" -import z from "zod" +import type { DeepMutable } from "@opencode-ai/core/schema" +import { Schema } from "effect" -const KeyStroke = z - .object({ - name: z.string(), - ctrl: z.boolean().optional(), - shift: z.boolean().optional(), - meta: z.boolean().optional(), - super: z.boolean().optional(), - hyper: z.boolean().optional(), - }) - .strict() +const KeyStroke = Schema.Struct({ + name: Schema.String, + ctrl: Schema.optional(Schema.Boolean), + shift: Schema.optional(Schema.Boolean), + meta: Schema.optional(Schema.Boolean), + super: Schema.optional(Schema.Boolean), + hyper: Schema.optional(Schema.Boolean), +}) -const BindingObject = z - .object({ - key: z.union([z.string(), KeyStroke]), - event: z.enum(["press", "release"]).optional(), - preventDefault: z.boolean().optional(), - fallthrough: z.boolean().optional(), - }) - .passthrough() +const BindingObject = Schema.StructWithRest( + Schema.Struct({ + key: Schema.Union([Schema.String, KeyStroke]), + event: Schema.optional(Schema.Literals(["press", "release"])), + preventDefault: Schema.optional(Schema.Boolean), + fallthrough: Schema.optional(Schema.Boolean), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) -const BindingItem = z.union([z.string(), KeyStroke, BindingObject]) -export const BindingValueSchema = z.union([z.literal(false), z.literal("none"), BindingItem, z.array(BindingItem)]) +const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject]) +export const BindingValueSchema = Schema.Union([ + Schema.Literal(false), + Schema.Literal("none"), + BindingItem, + Schema.Array(BindingItem), +]) +export type BindingValueSchema = DeepMutable> type Definition = { - default: z.input + default: BindingValueSchema description: string } @@ -214,21 +220,17 @@ export const Definitions = { which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"), } satisfies Record -type KeybindName = keyof typeof Definitions & string +type KeybindName = keyof typeof Definitions +const KeybindNames = new Set(Object.keys(Definitions)) -const KeybindShape = Object.fromEntries( - Object.entries(Definitions).map(([name, item]) => [ - name, - BindingValueSchema.optional().default(item.default).describe(item.description), - ]), -) as Record>> - -const KeybindOverrideShape = Object.fromEntries( - Object.entries(Definitions).map(([name, item]) => [name, BindingValueSchema.optional().describe(item.description)]), -) as Record> - -export const Keybinds = z.strictObject(KeybindShape).describe("TUI keybinding configuration") -export const KeybindOverrides = z.strictObject(KeybindOverrideShape).describe("TUI keybinding overrides") +export const KeybindOverrides = Schema.Struct( + Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [ + name, + Schema.optional(BindingValueSchema).annotate({ description: item.description }), + ]), + ), +).annotate({ description: "TUI keybinding overrides" }) export const Descriptions = Object.fromEntries( Object.entries(Definitions).map(([name, item]) => [name, item.description]), ) as Record @@ -387,8 +389,8 @@ const CommandDescriptions = Object.fromEntries( ]), ) as Record -export type Keybinds = z.output -export type KeybindOverrides = z.output +export type Keybinds = { [K in KeybindName]: BindingValueSchema } +export type KeybindOverrides = Partial export type BindingLookupView = { readonly bindings: readonly Binding[] get(command: string): readonly Binding[] @@ -402,6 +404,29 @@ export function toBindingConfig(keybinds: Keybinds): BindingConfig } +const decodeBindingValue = Schema.decodeUnknownSync(BindingValueSchema) + +export function defaultValue(name: KeybindName) { + return Definitions[name].default +} + +export function parse(keybinds: KeybindOverrides): Keybinds { + const invalid = unknownKeys(keybinds) + if (invalid.length) throw new Error(`Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`) + return Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [ + name, + decodeBindingValue(keybinds[name as KeybindName] ?? item.default), + ]), + ) as Keybinds +} + +export const Keybinds = { parse } + +export function unknownKeys(input: object) { + return Object.keys(input).filter((key) => !KeybindNames.has(key)) +} + export function bindingDefaults(): BindingDefaults { return ({ command, binding }) => { if (binding.desc !== undefined) return diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-json-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-json-schema.ts deleted file mode 100644 index 7784ce3ac6..0000000000 --- a/packages/opencode/src/cli/cmd/tui/config/tui-json-schema.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { ConfigPlugin } from "@/config/plugin" -import { Schema } from "effect" -import { TuiKeybind } from "./keybind" - -const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({ - description: "Leader key timeout in milliseconds", -}) - -const KeyStroke = Schema.Struct({ - name: Schema.String, - ctrl: Schema.optional(Schema.Boolean), - shift: Schema.optional(Schema.Boolean), - meta: Schema.optional(Schema.Boolean), - super: Schema.optional(Schema.Boolean), - hyper: Schema.optional(Schema.Boolean), -}) - -const BindingObject = Schema.StructWithRest( - Schema.Struct({ - key: Schema.Union([Schema.String, KeyStroke]), - event: Schema.optional(Schema.Literals(["press", "release"])), - preventDefault: Schema.optional(Schema.Boolean), - fallthrough: Schema.optional(Schema.Boolean), - }), - [Schema.Record(Schema.String, Schema.Unknown)], -) - -const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject]) -const BindingValue = Schema.Union([ - Schema.Literal(false), - Schema.Literal("none"), - BindingItem, - Schema.Array(BindingItem), -]) - -const KeybindOverrides = Schema.Struct( - Object.fromEntries( - Object.entries(TuiKeybind.Definitions).map(([name, item]) => [ - name, - Schema.optional(BindingValue).annotate({ description: item.description }), - ]), - ), -).annotate({ description: "TUI keybinding overrides" }) - -export const Info = Schema.Struct({ - $schema: Schema.optional(Schema.String), - theme: Schema.optional(Schema.String), - keybinds: Schema.optional(KeybindOverrides), - plugin: Schema.optional(Schema.Array(ConfigPlugin.Spec)), - plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), - leader_timeout: Schema.optional(KeymapLeaderTimeout), - scroll_speed: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))).annotate({ - description: "TUI scroll speed", - }), - scroll_acceleration: Schema.optional( - Schema.Struct({ - enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }), - }), - ).annotate({ description: "Scroll acceleration settings" }), - diff_style: Schema.optional(Schema.Literals(["auto", "stacked"])).annotate({ - description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column", - }), - mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), -}) - -export * as TuiJsonSchema from "./tui-json-schema" diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts index b90ce2a414..b4dc02d3b8 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts @@ -1,8 +1,8 @@ import path from "path" import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser" import { unique } from "remeda" -import z from "zod" -import { TuiInfo, TuiOptions } from "./tui-schema" +import { Option, Schema } from "effect" +import { DiffStyle, ScrollAcceleration, ScrollSpeed } from "./tui-schema" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { Filesystem } from "@/util/filesystem" @@ -13,16 +13,11 @@ const log = Log.create({ service: "tui.migrate" }) const TUI_SCHEMA_URL = "https://opencode.ai/tui.json" -const LegacyTheme = TuiInfo.shape.theme.optional() -const LegacyRecord = z.record(z.string(), z.unknown()).optional() - -const TuiLegacy = z - .object({ - scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined), - scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined), - diff_style: TuiOptions.shape.diff_style.catch(undefined), - }) - .strip() +const decodeTheme = Schema.decodeUnknownOption(Schema.String) +const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown)) +const decodeScrollSpeed = Schema.decodeUnknownOption(ScrollSpeed) +const decodeScrollAcceleration = Schema.decodeUnknownOption(ScrollAcceleration) +const decodeDiffStyle = Schema.decodeUnknownOption(DiffStyle) interface MigrateInput { cwd: string @@ -46,13 +41,13 @@ export async function migrateTuiConfig(input: MigrateInput) { const data = parseJsonc(source, errors, { allowTrailingComma: true }) if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue - const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined) - const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined) - const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined) + const theme = decodeTheme("theme" in data ? data.theme : undefined) + const keybinds = decodeRecord("keybinds" in data ? data.keybinds : undefined) + const legacyTui = decodeRecord("tui" in data ? data.tui : undefined) const extracted = { - theme: theme.success ? theme.data : undefined, - keybinds: keybinds.success ? keybinds.data : undefined, - tui: legacyTui.success ? legacyTui.data : undefined, + theme: Option.getOrUndefined(theme), + keybinds: Option.getOrUndefined(keybinds), + tui: Option.getOrUndefined(legacyTui), } const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue @@ -85,16 +80,23 @@ export async function migrateTuiConfig(input: MigrateInput) { } } -function normalizeTui(data: Record) { - const parsed = TuiLegacy.parse(data) - if ( - parsed.scroll_speed === undefined && +function normalizeTui(data: Record): + | { + scroll_speed: number | undefined + scroll_acceleration: { enabled: boolean } | undefined + diff_style: "auto" | "stacked" | undefined + } + | undefined { + const parsed = { + scroll_speed: Option.getOrUndefined(decodeScrollSpeed(data.scroll_speed)), + scroll_acceleration: Option.getOrUndefined(decodeScrollAcceleration(data.scroll_acceleration)), + diff_style: Option.getOrUndefined(decodeDiffStyle(data.diff_style)), + } + return parsed.scroll_speed === undefined && parsed.diff_style === undefined && parsed.scroll_acceleration === undefined - ) { - return - } - return parsed + ? undefined + : parsed } async function backupAndStripLegacy(file: string, source: string) { diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 318a702464..80765da3c7 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,33 +1,33 @@ -import z from "zod" import { ConfigPlugin } from "@/config/plugin" import { TuiKeybind } from "./keybind" +import { Schema } from "effect" export const KeymapLeaderTimeoutDefault = 2000 -const KeymapLeaderTimeout = z.number().int().positive().describe("Leader key timeout in milliseconds") - -export const TuiOptions = z.object({ - leader_timeout: KeymapLeaderTimeout.optional(), - scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), - scroll_acceleration: z - .object({ - enabled: z.boolean().describe("Enable scroll acceleration"), - }) - .optional() - .describe("Scroll acceleration settings"), - diff_style: z - .enum(["auto", "stacked"]) - .optional() - .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), - mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"), +const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({ + description: "Leader key timeout in milliseconds", }) -export const TuiInfo = z - .object({ - $schema: z.string().optional(), - theme: z.string().optional(), - keybinds: TuiKeybind.KeybindOverrides.optional(), - plugin: ConfigPlugin.Spec.zod.array().optional(), - plugin_enabled: z.record(z.string(), z.boolean()).optional(), - }) - .extend(TuiOptions.shape) - .strict() +export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001)) + +export const ScrollAcceleration = Schema.Struct({ + enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }), +}).annotate({ description: "Scroll acceleration settings" }) + +export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({ + description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column", +}) + +export const TuiInfo = Schema.Struct({ + $schema: Schema.optional(Schema.String), + theme: Schema.optional(Schema.String), + keybinds: Schema.optional(TuiKeybind.KeybindOverrides), + plugin: Schema.optional(Schema.Array(ConfigPlugin.Spec)), + plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + leader_timeout: Schema.optional(KeymapLeaderTimeout), + scroll_speed: Schema.optional(ScrollSpeed).annotate({ + description: "TUI scroll speed", + }), + scroll_acceleration: Schema.optional(ScrollAcceleration), + diff_style: Schema.optional(DiffStyle), + mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), +}) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 572f50c4d1..e53e20d343 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -1,9 +1,8 @@ export * as TuiConfig from "./tui" -import type z from "zod" import { createBindingLookup } from "@opentui/keymap/extras" import { mergeDeep, unique } from "remeda" -import { Context, Effect, Fiber, Layer } from "effect" +import { Context, Effect, Fiber, Layer, Schema } from "effect" import { ConfigParse } from "@/config/parse" import { InvalidError } from "@/config/error" import * as ConfigPaths from "@/config/paths" @@ -22,11 +21,12 @@ import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" +import type { DeepMutable } from "@opencode-ai/core/schema" const log = Log.create({ service: "tui.config" }) export const Info = TuiInfo -export type Info = z.output +export type Info = DeepMutable> type Acc = { result: Info @@ -91,9 +91,17 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: if (!isRecord(data)) return {} as Info // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json // (mirroring the old opencode.json shape) still get their settings applied. - const parsed = Info.safeParse(normalize(data)) - if (!parsed.success) throw new InvalidError({ path: configFilepath, issues: parsed.error.issues }) - const validated = parsed.data + const normalized = normalize(data) + if (isRecord(normalized.keybinds)) { + const invalid = TuiKeybind.unknownKeys(normalized.keybinds) + if (invalid.length) { + throw new InvalidError({ + path: configFilepath, + message: `Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`, + }) + } + } + const validated = ConfigParse.schema(Info, normalized, configFilepath) return yield* resolvePlugins(validated, configFilepath) }).pipe( // catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation @@ -179,16 +187,14 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: } } - const keybinds = { ...(acc.result.keybinds ?? {}) } + const keybinds = { ...acc.result.keybinds } if (process.platform === "win32") { // Native Windows terminals do not support POSIX suspend, so prefer prompt undo. keybinds.terminal_suspend = "none" - keybinds.input_undo ??= unique([ - "ctrl+z", - ...String(TuiKeybind.Keybinds.shape.input_undo.parse(undefined)).split(","), - ]).join(",") + const inputUndo = TuiKeybind.defaultValue("input_undo") + keybinds.input_undo ??= unique(["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]).join(",") } - const parsedKeybinds = TuiKeybind.Keybinds.parse(keybinds) + const parsedKeybinds = TuiKeybind.parse(keybinds) const result: Resolved = { ...acc.result, keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), { diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index c70442427c..1c4d4037eb 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -2,8 +2,6 @@ import { Glob } from "@opencode-ai/core/util/glob" import { Schema } from "effect" import { pathToFileURL } from "url" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" -import { zod } from "@opencode-ai/core/effect-zod" -import { withStatics } from "@opencode-ai/core/schema" import path from "path" export const Options = Schema.Record(Schema.String, Schema.Unknown) @@ -11,9 +9,7 @@ export type Options = Schema.Schema.Type // Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options. // It answers "what should we load?" but says nothing about where that value came from. -export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe( - withStatics((s) => ({ zod: zod(s) })), -) +export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]) export type Spec = Schema.Schema.Type export type Scope = "global" | "local" From fe374aea46abf995a596c8a3a96ed7da601571c9 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 12 May 2026 07:59:38 +0800 Subject: [PATCH 04/32] feat(app): persist todo dock collapsed state (#26953) --- packages/app/src/context/layout.tsx | 13 ++++++++++++ .../composer/session-composer-region.tsx | 5 +++++ .../session/composer/session-todo-dock.tsx | 21 +++++++++---------- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index cacc875c54..0d37dd26af 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -43,6 +43,7 @@ type SessionView = { reviewOpen?: string[] pendingMessage?: string pendingMessageAt?: number + todoCollapsed?: boolean } type TabHandoff = { @@ -759,6 +760,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setScroll(tab: string, pos: SessionScroll) { scroll.setScroll(key(), tab, pos) }, + todoCollapsed: { + get: () => s().todoCollapsed ?? false, + set(collapsed: boolean) { + const session = key() + const current = store.sessionView[session] + if (!current) { + setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed }) + } else { + setStore("sessionView", session, "todoCollapsed", collapsed) + } + }, + }, terminal: { opened: terminalOpened, open() { diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 60447566ed..e6bfd05ec4 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -2,6 +2,7 @@ import { Show, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useNavigate } from "@solidjs/router" import { useSpring } from "@opencode-ai/ui/motion-spring" +import { useLayout } from "@/context/layout" import { PromptInput } from "@/components/prompt-input" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" @@ -46,10 +47,12 @@ export function SessionComposerRegion(props: { setPromptDockRef: (el: HTMLDivElement) => void }) { const navigate = useNavigate() + const layout = useLayout() const prompt = usePrompt() const language = useLanguage() const route = useSessionKey() const sync = useSync() + const view = layout.view(route.sessionKey) const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt) const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined)) @@ -207,6 +210,8 @@ export function SessionComposerRegion(props: { view.todoCollapsed.set(!view.todoCollapsed.get())} collapseLabel={language.t("session.todo.collapse")} expandLabel={language.t("session.todo.expand")} dockProgress={value()} diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index fa8c177343..fccbeec177 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -42,18 +42,17 @@ function dot(status: Todo["status"]) { export function SessionTodoDock(props: { sessionID?: string todos: Todo[] + collapsed: boolean + onToggle: () => void collapseLabel: string expandLabel: string dockProgress: number }) { const language = useLanguage() const [store, setStore] = createStore({ - collapsed: false, height: 320, }) - const toggle = () => setStore("collapsed", (value) => !value) - const total = createMemo(() => props.todos.length) const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length) const label = createMemo(() => language.t("session.todo.progress", { done: done(), total: total() })) @@ -72,7 +71,7 @@ export function SessionTodoDock(props: { ) const preview = createMemo(() => active()?.content ?? "") - const collapse = useSpring(() => (store.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) + const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress))) const shut = createMemo(() => 1 - dock()) const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) @@ -107,11 +106,11 @@ export function SessionTodoDock(props: { class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible" role="button" tabIndex={0} - onClick={toggle} + onClick={props.onToggle} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return event.preventDefault() - toggle() + props.onToggle() }} > { event.stopPropagation() - toggle() + props.onToggle() }} - aria-label={store.collapsed ? props.expandLabel : props.collapseLabel} + aria-label={props.collapsed ? props.expandLabel : props.collapseLabel} />
0.1, }} From 061efc6cf260762677bdbac9261725a2da4fdbea Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 20:30:23 -0400 Subject: [PATCH 05/32] Fix run JSON output draining (#26955) --- packages/opencode/src/cli/cmd/run.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index bca89c3cab..7011b51eb9 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -719,6 +719,7 @@ export const RunCommand = effectCmd({ } } } + return error } const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root) const client = args.attach ? attachSDK(cwd) : sdk @@ -730,10 +731,7 @@ export const RunCommand = effectCmd({ if (!args.interactive) { const events = await client.event.subscribe() - loop(client, events).catch((e) => { - console.error(e) - process.exit(1) - }) + const completed = loop(client, events) if (args.command) { await client.session.command({ @@ -744,6 +742,8 @@ export const RunCommand = effectCmd({ arguments: message, variant: args.variant, }) + const error = await completed + if (error) process.exitCode = 1 return } @@ -755,6 +755,8 @@ export const RunCommand = effectCmd({ variant: args.variant, parts: [...files, { type: "text", text: message }], }) + const error = await completed + if (error) process.exitCode = 1 return } From ec9584177fc7a00b9f02c74ee4fde7850cee4825 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 20:32:27 -0400 Subject: [PATCH 06/32] docs(test): plan Effect test migration (#26954) --- .../opencode/test/EFFECT_TEST_MIGRATION.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 packages/opencode/test/EFFECT_TEST_MIGRATION.md diff --git a/packages/opencode/test/EFFECT_TEST_MIGRATION.md b/packages/opencode/test/EFFECT_TEST_MIGRATION.md new file mode 100644 index 0000000000..20472a6312 --- /dev/null +++ b/packages/opencode/test/EFFECT_TEST_MIGRATION.md @@ -0,0 +1,215 @@ +# Effect Test Migration Plan + +This document describes how to move opencode tests out of Promise-land and into the shared `testEffect` pattern. + +## Target Pattern + +Every test file that exercises Effect services should have one local runner near the top: + +```ts +const it = testEffect(layer) +``` + +Then each test should use one of the runner methods: + +```ts +it.effect("pure service behavior", () => + Effect.gen(function* () { + const service = yield* SomeService.Service + expect(yield* service.run()).toEqual("ok") + }), +) + +it.instance("instance-local behavior", () => + Effect.gen(function* () { + const test = yield* TestInstance + // test.directory is a scoped temp opencode instance + }), +) + +it.live("live filesystem or process behavior", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + // real clock / fs / git / process work + }), +) +``` + +Use `it.effect` for pure Effect code that should run with `TestClock` and `TestConsole`. +Use `it.instance` when the test needs one scoped opencode instance. +Use `it.live` when the test depends on real time, filesystem mtimes, git, child processes, servers, file watchers, or OS behavior. + +## Anti-Patterns To Remove + +Avoid these in tests that already target Effect services: + +- `test(..., async () => Effect.runPromise(...))` +- local `run(...)`, `load(...)`, `svc(...)`, or `runtime.runPromise(...)` wrappers that only provide a layer +- `tmpdir()` plus `WithInstance.provide(...)` in Promise test bodies +- custom `ManagedRuntime.make(...)` in test files +- Promise `try/catch` around Effect failures +- `Promise.withResolvers`, `Bun.sleep`, or `setTimeout` for synchronization when `Deferred`, `Fiber`, or `Effect.sleep` can express the same behavior + +Promise helpers are acceptable at the boundary for non-Effect APIs, but they should be yielded from an Effect body with `Effect.promise(...)` rather than becoming the test harness. + +## Layer Rules + +Compose tests from open service layers, not closed `defaultLayer` graphs when a dependency needs replacing. + +Good: + +```ts +const layer = Config.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(AuthTest.empty), + Layer.provide(AccountTest.empty), + Layer.provide(NpmTest.noop), +) +``` + +Avoid using a fully closed layer and hoping to override an inner dependency later. Once `Agent.defaultLayer` has already provided `Config.defaultLayer`, tests cannot cleanly swap the `Npm.Service` used by that config layer. + +Prefer small reusable fake boundary layers in `test/fake/*`: + +```ts +AuthTest.empty +AccountTest.empty +NpmTest.noop +SkillTest.empty +ProviderTest.fake().layer +``` + +Do not add generic test-layer builders until repeated local compositions prove the need. Shared fake boundary services are the first reusable unit. Pre-composed subtrees such as `AgentTest.withPlugins` should come later, only after the same graph appears in multiple files. + +## Fixture Rules + +Use Effect-aware fixtures from `test/fixture/fixture.ts`: + +- `TestInstance` inside `it.instance(...)` for the current temp instance path +- `tmpdirScoped(...)` inside `Effect.gen` for additional temp directories +- `provideInstance(dir)(effect)` when one test needs to switch instance context +- `provideTmpdirInstance((dir) => effect, options)` when a live test needs custom instance setup or multiple instance scopes +- `disposeAllInstances()` in `afterEach` only for integration tests that intentionally touch shared instance registries + +Use finalizers only as a temporary bridge for existing global mutations: + +```ts +yield* Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.MY_FLAG + process.env.MY_FLAG = "1" + return previous + }), + () => testBody, + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.MY_FLAG + else process.env.MY_FLAG = previous + }), +) +``` + +TODO: eliminate this pattern over time. Tests should not toggle process-global flags or env vars when the behavior can be modeled with services. Prefer moving flag/env reads behind injectable services such as `Config.Service`, `Env.Service`, or focused test layers, then provide the desired test value through the layer graph instead of mutating `process.env` or `Global.Path`. + +## Conversion Recipe + +1. Identify the real service under test and its open `*.layer`. +2. Build one top-level `layer` with real dependencies where they are relevant and `test/fake/*` layers at slow or external boundaries. +3. Replace local Promise wrappers with Effect helpers: + +```ts +const run = Effect.fn("MyTest.run")(function* (input: Input) { + const service = yield* MyService.Service + return yield* service.run(input) +}) +``` + +4. Convert `test(..., async () => { ... })` to `it.effect`, `it.instance`, or `it.live`. +5. Move `await` calls inside `Effect.gen` as `yield*` calls. +6. Replace `await using tmp = await tmpdir(...)` with `yield* tmpdirScoped(...)` when the temp directory is inside an Effect test. +7. Replace `WithInstance.provide({ directory, fn })` with `it.instance(...)`, `provideInstance(directory)(effect)`, or `provideTmpdirInstance(...)`. +8. Replace Promise failure assertions with Effect assertions: + +```ts +const exit = yield* run(input).pipe(Effect.exit) +expect(Exit.isFailure(exit)).toBe(true) +``` + +This is correct but still verbose. Track repeated assertion shapes during migration so we can add small test assertion helpers later instead of copying low-level `Exit` plumbing everywhere. + +9. Keep concurrency concurrent by using `Effect.forkScoped`, `Fiber.join`, `Deferred`, or `Effect.all(..., { concurrency: "unbounded" })` instead of serializing formerly parallel Promise work. +10. Run the focused test file and `bun typecheck` from `packages/opencode`. + +## Good Examples + +Use these files as models: + +- `test/tool/write.test.ts`: strong `it.instance` tests, top-level `testEffect(...)`, and Effect-native test helpers. +- `test/effect/instance-state.test.ts`: good `it.live` use for scoped directories, instance switching, reload/disposal, and concurrency. +- `test/bus/bus-effect.test.ts`: good `Deferred`, streams, and scoped fibers. +- `test/tool/truncation.test.ts`: good configured runners and concise live service tests. +- `test/tool/repo_clone.test.ts`: good live git integration while staying inside Effect fixtures. +- `test/server/httpapi-instance.test.ts`: good scoped integration layer setup and live HTTP assertions. +- `test/account/service.test.ts`: good service-level live tests, `Effect.flip`, typed errors, and fake HTTP clients. +- `test/agent/plugin-agent-regression.test.ts`: good example of open real service layers plus reusable fake boundary layers. + +## Current Promise-Land Hotspots + +Start with files that already exercise Effect services but still manually run Promises: + +- `test/config/config.test.ts`: many `Effect.runPromise`, `tmpdir()`, and `WithInstance.provide(...)` patterns despite already having `const it = testEffect(layer)`. +- `test/tool/shell.test.ts`: custom `ManagedRuntime`, Promise test helpers, and instance setup around shell execution. +- `test/tool/edit.test.ts`: manual runtime helpers and Promise concurrency patterns that should become fibers/deferreds. +- `test/session/messages-pagination.test.ts`: local Promise service facade over `Session.defaultLayer`. +- `test/snapshot/snapshot.test.ts`: Promise helper with `provideInstance` around snapshot operations. +- `test/file/index.test.ts`: Promise wrappers for `File.Service` plus repeated temp instance setup. +- `test/provider/provider.test.ts`: `AppRuntime.runPromise` helpers and mutable env/config setup. +- `test/project/vcs.test.ts`: Promise event waiting and `AppRuntime.runPromise` around VCS service calls. + +## Migration Order + +1. Convert one small file with straightforward service calls and no race behavior. +2. Convert `config.test.ts` incrementally by cluster, not in one PR. +3. Extract additional `test/fake/*` boundary layers only when a second test needs the same fake. +4. Convert files with concurrency or watchers after the simple files, preserving timing semantics with `Deferred` and fibers. +5. Leave pure non-Effect utility tests alone unless converting the underlying code to Effect. + +## Claimable Checklist + +Use this as a migration queue. Each checkbox should be safe for one agent or one PR unless the notes say otherwise. Agents should claim one item, convert only that file or cluster, run the focused test file, run `bun typecheck`, and update this checklist in the PR description or follow-up note. + +- [ ] `test/file/index.test.ts`: straightforward service wrapper cleanup. Replace local Promise helpers with Effect helpers and use `it.instance` / `it.live` around existing temp instance cases. +- [ ] `test/session/messages-pagination.test.ts`: convert the local `run(...)` / `svc(...)` facade to `testEffect(Session.defaultLayer...)` and direct service yields. Good early target. +- [ ] `test/snapshot/snapshot.test.ts`: convert snapshot operations to `it.live` with `tmpdirScoped` / `provideInstance`. Keep git/filesystem behavior live. +- [ ] `test/project/vcs.test.ts`: convert `AppRuntime.runPromise` service calls first. Leave event/watcher timing intact until the first Effect version is stable. +- [ ] `test/provider/provider.test.ts` cluster 1: convert provider service tests that only read config/env and do not mutate global state heavily. +- [ ] `test/provider/provider.test.ts` cluster 2: convert tests with env/config mutation after introducing or reusing service-backed test seams. +- [ ] `test/tool/shell.test.ts`: replace custom `ManagedRuntime` with `testEffect`, keep as `it.live`, and preserve process behavior. +- [ ] `test/tool/edit.test.ts` cluster 1: convert straightforward edit/read/write cases and remove manual runtime helpers. +- [ ] `test/tool/edit.test.ts` cluster 2: convert concurrency/race tests using `Deferred`, fibers, and `Effect.all` without serializing behavior. +- [ ] `test/config/config.test.ts` setup pass: replace inline fake layers with shared `test/fake/*` layers where possible and turn Promise helpers into Effect helpers. +- [ ] `test/config/config.test.ts` cluster 1: convert simple config load/merge tests that only need one instance. +- [ ] `test/config/config.test.ts` cluster 2: convert managed/global config tests that mutate `Global.Path` or managed config directories. Prefer service seams; use finalizers only as a bridge. +- [ ] `test/config/config.test.ts` cluster 3: convert plugin/dependency tests after ensuring `NpmTest.noop` or explicit fake NPM layers are used. +- [ ] `test/config/config.test.ts` cluster 4: convert remote/account/provider config tests after isolating auth/account/env dependencies through layers. +- [ ] Audit remaining `Effect.runPromise` in `packages/opencode/test/**/*.ts` and create follow-up checklist entries for any missed files. +- [ ] Audit remaining `WithInstance.provide` in `packages/opencode/test/**/*.ts` and convert cases that can use `it.instance` or `provideInstance` inside Effect. +- [ ] Audit repeated `Exit` / `Cause` assertion shapes and propose `test/lib/effect-assert.ts` helpers if at least three files repeat the same pattern. + +Parallelization notes: + +- The first four items are mostly independent and good for parallel agents. +- `provider.test.ts`, `tool/edit.test.ts`, and `config.test.ts` should be split by cluster so agents do not edit the same file concurrently. +- Any new fake boundary layer under `test/fake/*` should be small and independently useful. Do not add a fake just for one assertion unless it removes a real external dependency. +- Do not combine assertion-helper design with file migrations. First collect repeated shapes, then add helpers in a separate pass. + +## Effectified Test Rough Edges + +Track patterns that are technically Effect-native but still too noisy. These should become a second cleanup pass after the Promise-land migration is underway. + +- Failure assertions against `Exit` / `Cause` are often verbose. Consider helpers such as `expectEffectFailure(effect)`, `expectTaggedError(effect, Tag)`, or custom Bun matchers if the same shapes repeat. +- Some tests still need `Effect.promise(...)` around Node/Bun filesystem helpers. Prefer Effect platform services when the surrounding code already uses them, but do not block migrations on perfect filesystem abstraction. +- Scoped global mutation with `process.env`, `Global.Path`, or flags should disappear behind injectable services over time. +- Layer composition can be noisy when a test needs a real service subtree plus fake boundaries. Keep extracting small `test/fake/*` boundary layers before inventing larger builders. +- Concurrency tests can become harder to read after replacing Promise resolvers with `Deferred` and fibers. Look for repeated patterns that deserve named helpers. From 8015ff7ca5c17e272181a915be1c5cec0b26e88b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 00:33:34 +0000 Subject: [PATCH 07/32] chore: generate --- .../opencode/test/EFFECT_TEST_MIGRATION.md | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/opencode/test/EFFECT_TEST_MIGRATION.md b/packages/opencode/test/EFFECT_TEST_MIGRATION.md index 20472a6312..60cd332642 100644 --- a/packages/opencode/test/EFFECT_TEST_MIGRATION.md +++ b/packages/opencode/test/EFFECT_TEST_MIGRATION.md @@ -95,19 +95,20 @@ Use Effect-aware fixtures from `test/fixture/fixture.ts`: Use finalizers only as a temporary bridge for existing global mutations: ```ts -yield* Effect.acquireUseRelease( - Effect.sync(() => { - const previous = process.env.MY_FLAG - process.env.MY_FLAG = "1" - return previous - }), - () => testBody, - (previous) => +yield * + Effect.acquireUseRelease( Effect.sync(() => { - if (previous === undefined) delete process.env.MY_FLAG - else process.env.MY_FLAG = previous + const previous = process.env.MY_FLAG + process.env.MY_FLAG = "1" + return previous }), -) + () => testBody, + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.MY_FLAG + else process.env.MY_FLAG = previous + }), + ) ``` TODO: eliminate this pattern over time. Tests should not toggle process-global flags or env vars when the behavior can be modeled with services. Prefer moving flag/env reads behind injectable services such as `Config.Service`, `Env.Service`, or focused test layers, then provide the desired test value through the layer graph instead of mutating `process.env` or `Global.Path`. @@ -132,7 +133,7 @@ const run = Effect.fn("MyTest.run")(function* (input: Input) { 8. Replace Promise failure assertions with Effect assertions: ```ts -const exit = yield* run(input).pipe(Effect.exit) +const exit = yield * run(input).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) ``` From fbd52ca2f4fcbeb23f070b8a010cda40129f207b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 20:39:31 -0400 Subject: [PATCH 08/32] test(file): migrate file tests to Effect runner (#26959) --- packages/opencode/test/file/index.test.ts | 1416 ++++++++++----------- 1 file changed, 651 insertions(+), 765 deletions(-) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index cdd2e211c2..9250841404 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -1,557 +1,489 @@ -import { afterEach, describe, test, expect } from "bun:test" +import { afterEach, describe, expect } from "bun:test" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { $ } from "bun" -import { Effect } from "effect" +import { Cause, Effect, Exit, Layer } from "effect" import path from "path" import fs from "fs/promises" import { File } from "../../src/file" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" -import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, TestInstance, withTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" afterEach(async () => { await disposeAllInstances() }) -const init = () => run(File.Service.use((svc) => svc.init())) -const run = (eff: Effect.Effect) => - Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer)))) -const status = () => run(File.Service.use((svc) => svc.status())) -const read = (file: string) => run(File.Service.use((svc) => svc.read(file))) -const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir))) -const search = (input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) => - run(File.Service.use((svc) => svc.search(input))) +const it = testEffect(Layer.mergeAll(File.defaultLayer, AppFileSystem.defaultLayer)) + +const init = Effect.fn("FileTest.init")(function* () { + const file = yield* File.Service + return yield* file.init() +}) + +const status = Effect.fn("FileTest.status")(function* () { + const file = yield* File.Service + return yield* file.status() +}) + +const read = Effect.fn("FileTest.read")(function* (input: string) { + const file = yield* File.Service + return yield* file.read(input) +}) + +const list = Effect.fn("FileTest.list")(function* (dir?: string) { + const file = yield* File.Service + return yield* file.list(dir) +}) + +const search = Effect.fn("FileTest.search")(function* (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" +}) { + const file = yield* File.Service + return yield* file.search(input) +}) + +const gitAddAll = (directory: string) => Effect.promise(() => $`git add .`.cwd(directory).quiet()) +const gitCommit = (directory: string, message: string) => + Effect.promise(() => $`git commit -m ${message}`.cwd(directory).quiet()) + +const failureMessage = (self: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* self.pipe(Effect.exit) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + return error instanceof Error ? error.message : String(error) + } + throw new Error("expected effect to fail") + }) + +const setupSearchableRepo = Effect.fn("FileTest.setupSearchableRepo")(function* (directory: string) { + const fsys = yield* AppFileSystem.Service + yield* fsys.writeWithDirs(path.join(directory, "index.ts"), "code") + yield* fsys.writeWithDirs(path.join(directory, "utils.ts"), "utils") + yield* fsys.writeWithDirs(path.join(directory, "readme.md"), "readme") + yield* fsys.writeWithDirs(path.join(directory, "src", "main.ts"), "main") + yield* fsys.writeWithDirs(path.join(directory, ".hidden", "secret.ts"), "secret") +}) describe("file/index Filesystem patterns", () => { describe("read() - text content", () => { - test("reads text file via Filesystem.readText()", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.txt") - await fs.writeFile(filepath, "Hello World", "utf-8") + it.instance("reads text file via Filesystem.readText()", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "Hello World", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("test.txt") - expect(result.type).toBe("text") - expect(result.content).toBe("Hello World") - }, - }) - }) + const result = yield* read("test.txt") + expect(result.type).toBe("text") + expect(result.content).toBe("Hello World") + }), + ) - test("reads with Filesystem.exists() check", async () => { - await using tmp = await tmpdir() + it.instance("reads with Filesystem.exists() check", () => + Effect.gen(function* () { + const result = yield* read("nonexistent.txt") + expect(result.type).toBe("text") + expect(result.content).toBe("") + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // Non-existent file should return empty content - const result = await read("nonexistent.txt") - expect(result.type).toBe("text") - expect(result.content).toBe("") - }, - }) - }) + it.instance("trims whitespace from text content", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "test.txt"), " content with spaces \n\n", "utf-8"), + ) - test("trims whitespace from text content", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.txt") - await fs.writeFile(filepath, " content with spaces \n\n", "utf-8") + const result = yield* read("test.txt") + expect(result.content).toBe("content with spaces") + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("test.txt") - expect(result.content).toBe("content with spaces") - }, - }) - }) + it.instance("handles empty text file", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "empty.txt"), "", "utf-8")) - test("handles empty text file", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "empty.txt") - await fs.writeFile(filepath, "", "utf-8") + const result = yield* read("empty.txt") + expect(result.type).toBe("text") + expect(result.content).toBe("") + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("empty.txt") - expect(result.type).toBe("text") - expect(result.content).toBe("") - }, - }) - }) + it.instance("handles multi-line text files", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "multiline.txt"), "line1\nline2\nline3", "utf-8")) - test("handles multi-line text files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "multiline.txt") - await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("multiline.txt") - expect(result.content).toBe("line1\nline2\nline3") - }, - }) - }) + const result = yield* read("multiline.txt") + expect(result.content).toBe("line1\nline2\nline3") + }), + ) }) describe("read() - binary content", () => { - test("reads binary file via Filesystem.readArrayBuffer()", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "image.png") - const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - await fs.writeFile(filepath, binaryContent) + it.instance("reads binary file via Filesystem.readArrayBuffer()", () => + Effect.gen(function* () { + const test = yield* TestInstance + const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "image.png"), binaryContent)) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("image.png") - expect(result.type).toBe("text") // Images return as text with base64 encoding - expect(result.encoding).toBe("base64") - expect(result.mimeType).toBe("image/png") - expect(result.content).toBe(binaryContent.toString("base64")) - }, - }) - }) + const result = yield* read("image.png") + expect(result.type).toBe("text") + expect(result.encoding).toBe("base64") + expect(result.mimeType).toBe("image/png") + expect(result.content).toBe(binaryContent.toString("base64")) + }), + ) - test("returns empty for binary non-image files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "binary.so") - await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary") + it.instance("returns empty for binary non-image files", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "binary.so"), Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("binary.so") - expect(result.type).toBe("binary") - expect(result.content).toBe("") - }, - }) - }) + const result = yield* read("binary.so") + expect(result.type).toBe("binary") + expect(result.content).toBe("") + }), + ) }) describe("read() - Filesystem.mimeType()", () => { - test("detects MIME type via Filesystem.mimeType()", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.json") - await fs.writeFile(filepath, '{"key": "value"}', "utf-8") + it.instance("detects MIME type via Filesystem.mimeType()", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "test.json") + yield* Effect.promise(() => fs.writeFile(filepath, '{"key": "value"}', "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - expect(await Filesystem.mimeType(filepath)).toContain("application/json") + expect(yield* Effect.promise(() => Filesystem.mimeType(filepath))).toContain("application/json") - const result = await read("test.json") - expect(result.type).toBe("text") - }, - }) - }) + const result = yield* read("test.json") + expect(result.type).toBe("text") + }), + ) - test("handles various image MIME types", async () => { - await using tmp = await tmpdir() - const testCases = [ - { ext: "jpg", mime: "image/jpeg" }, - { ext: "png", mime: "image/png" }, - { ext: "gif", mime: "image/gif" }, - { ext: "webp", mime: "image/webp" }, - ] + it.instance("handles various image MIME types", () => + Effect.gen(function* () { + const test = yield* TestInstance + const testCases = [ + { ext: "jpg", mime: "image/jpeg" }, + { ext: "png", mime: "image/png" }, + { ext: "gif", mime: "image/gif" }, + { ext: "webp", mime: "image/webp" }, + ] - for (const { ext, mime } of testCases) { - const filepath = path.join(tmp.path, `test.${ext}`) - await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary") - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - expect(await Filesystem.mimeType(filepath)).toContain(mime) - }, - }) - } - }) + for (const testCase of testCases) { + const filepath = path.join(test.directory, `test.${testCase.ext}`) + yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]))) + expect(yield* Effect.promise(() => Filesystem.mimeType(filepath))).toContain(testCase.mime) + } + }), + ) }) describe("list() - Filesystem.exists() and readText()", () => { - test("reads .gitignore via Filesystem.exists() and readText()", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance( + "reads .gitignore via Filesystem.exists() and readText()", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const gitignorePath = path.join(test.directory, ".gitignore") + yield* Effect.promise(() => fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const gitignorePath = path.join(tmp.path, ".gitignore") - await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8") + expect(yield* Effect.promise(() => Filesystem.exists(gitignorePath))).toBe(true) + expect(yield* Effect.promise(() => Filesystem.readText(gitignorePath))).toContain("node_modules") + }), + { git: true }, + ) - // This is used internally in list() - expect(await Filesystem.exists(gitignorePath)).toBe(true) + it.instance( + "reads .ignore file similarly", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const ignorePath = path.join(test.directory, ".ignore") + yield* Effect.promise(() => fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8")) - const content = await Filesystem.readText(gitignorePath) - expect(content).toContain("node_modules") - }, - }) - }) + expect(yield* Effect.promise(() => Filesystem.exists(ignorePath))).toBe(true) + expect(yield* Effect.promise(() => Filesystem.readText(ignorePath))).toContain("*.log") + }), + { git: true }, + ) - test("reads .ignore file similarly", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance( + "handles missing .gitignore gracefully", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const gitignorePath = path.join(test.directory, ".gitignore") + expect(yield* Effect.promise(() => Filesystem.exists(gitignorePath))).toBe(false) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const ignorePath = path.join(tmp.path, ".ignore") - await fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8") - - expect(await Filesystem.exists(ignorePath)).toBe(true) - expect(await Filesystem.readText(ignorePath)).toContain("*.log") - }, - }) - }) - - test("handles missing .gitignore gracefully", async () => { - await using tmp = await tmpdir({ git: true }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const gitignorePath = path.join(tmp.path, ".gitignore") - expect(await Filesystem.exists(gitignorePath)).toBe(false) - - // list() should still work - const nodes = await list() + const nodes = yield* list() expect(Array.isArray(nodes)).toBe(true) - }, - }) - }) + }), + { git: true }, + ) }) describe("File.changed() - Filesystem.readText() for untracked files", () => { - test("reads untracked files via Filesystem.readText()", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance( + "reads untracked files via Filesystem.readText()", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const untrackedPath = path.join(test.directory, "untracked.txt") + yield* Effect.promise(() => fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const untrackedPath = path.join(tmp.path, "untracked.txt") - await fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8") - - // This is how File.changed() reads untracked files - const content = await Filesystem.readText(untrackedPath) - const lines = content.split("\n").length - expect(lines).toBe(2) - }, - }) - }) + const content = yield* Effect.promise(() => Filesystem.readText(untrackedPath)) + expect(content.split("\n").length).toBe(2) + }), + { git: true }, + ) }) describe("Error handling", () => { - test("handles errors gracefully in Filesystem.readText()", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "readonly.txt") - await fs.writeFile(filepath, "content", "utf-8") + it.instance("handles errors gracefully in Filesystem.readText()", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "readonly.txt"), "content", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nonExistentPath = path.join(tmp.path, "does-not-exist.txt") - // Filesystem.readText() on non-existent file throws - await expect(Filesystem.readText(nonExistentPath)).rejects.toThrow() + const nonExistentPath = path.join(test.directory, "does-not-exist.txt") + expect(Exit.isFailure(yield* Effect.promise(() => Filesystem.readText(nonExistentPath)).pipe(Effect.exit))).toBe(true) - // But read() handles this gracefully - const result = await read("does-not-exist.txt") - expect(result.content).toBe("") - }, - }) - }) + const result = yield* read("does-not-exist.txt") + expect(result.content).toBe("") + }), + ) - test("handles errors in Filesystem.readArrayBuffer()", async () => { - await using tmp = await tmpdir() + it.instance("handles errors in Filesystem.readArrayBuffer()", () => + Effect.gen(function* () { + const test = yield* TestInstance + const nonExistentPath = path.join(test.directory, "does-not-exist.bin") + const buffer = yield* Effect.promise(() => Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0))) + expect(buffer.byteLength).toBe(0) + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nonExistentPath = path.join(tmp.path, "does-not-exist.bin") - const buffer = await Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0)) - expect(buffer.byteLength).toBe(0) - }, - }) - }) - - test("returns empty array buffer on error for images", async () => { - await using tmp = await tmpdir() - const _filepath = path.join(tmp.path, "broken.png") - // Don't create the file - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // read() handles missing images gracefully - const result = await read("broken.png") - expect(result.type).toBe("text") - expect(result.content).toBe("") - }, - }) - }) + it.instance("returns empty array buffer on error for images", () => + Effect.gen(function* () { + const result = yield* read("broken.png") + expect(result.type).toBe("text") + expect(result.content).toBe("") + }), + ) }) describe("shouldEncode() logic", () => { - test("treats .ts files as text", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.ts") - await fs.writeFile(filepath, "export const value = 1", "utf-8") + it.instance("treats .ts files as text", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.ts"), "export const value = 1", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("test.ts") - expect(result.type).toBe("text") - expect(result.content).toBe("export const value = 1") - }, - }) - }) + const result = yield* read("test.ts") + expect(result.type).toBe("text") + expect(result.content).toBe("export const value = 1") + }), + ) - test("treats .mts files as text", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.mts") - await fs.writeFile(filepath, "export const value = 1", "utf-8") + it.instance("treats .mts files as text", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.mts"), "export const value = 1", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("test.mts") - expect(result.type).toBe("text") - expect(result.content).toBe("export const value = 1") - }, - }) - }) + const result = yield* read("test.mts") + expect(result.type).toBe("text") + expect(result.content).toBe("export const value = 1") + }), + ) - test("treats .sh files as text", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.sh") - await fs.writeFile(filepath, "#!/usr/bin/env bash\necho hello", "utf-8") + it.instance("treats .sh files as text", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.sh"), "#!/usr/bin/env bash\necho hello", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("test.sh") - expect(result.type).toBe("text") - expect(result.content).toBe("#!/usr/bin/env bash\necho hello") - }, - }) - }) + const result = yield* read("test.sh") + expect(result.type).toBe("text") + expect(result.content).toBe("#!/usr/bin/env bash\necho hello") + }), + ) - test("treats Dockerfile as text", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "Dockerfile") - await fs.writeFile(filepath, "FROM alpine:3.20", "utf-8") + it.instance("treats Dockerfile as text", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "Dockerfile"), "FROM alpine:3.20", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("Dockerfile") - expect(result.type).toBe("text") - expect(result.content).toBe("FROM alpine:3.20") - }, - }) - }) + const result = yield* read("Dockerfile") + expect(result.type).toBe("text") + expect(result.content).toBe("FROM alpine:3.20") + }), + ) - test("returns encoding info for text files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.txt") - await fs.writeFile(filepath, "simple text", "utf-8") + it.instance("returns encoding info for text files", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "simple text", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("test.txt") - expect(result.encoding).toBeUndefined() - expect(result.type).toBe("text") - }, - }) - }) + const result = yield* read("test.txt") + expect(result.encoding).toBeUndefined() + expect(result.type).toBe("text") + }), + ) - test("returns base64 encoding for images", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "test.jpg") - await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary") + it.instance("returns base64 encoding for images", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.jpg"), Buffer.from([0xff, 0xd8, 0xff, 0xe0]))) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("test.jpg") - expect(result.encoding).toBe("base64") - expect(result.mimeType).toBe("image/jpeg") - }, - }) - }) + const result = yield* read("test.jpg") + expect(result.encoding).toBe("base64") + expect(result.mimeType).toBe("image/jpeg") + }), + ) }) describe("Path security", () => { - test("throws for paths outside project directory", async () => { - await using tmp = await tmpdir() + it.instance("throws for paths outside project directory", () => + Effect.gen(function* () { + expect(yield* failureMessage(read("../outside.txt"))).toContain("Access denied") + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(read("../outside.txt")).rejects.toThrow("Access denied") - }, - }) - }) - - test("throws for paths outside project directory", async () => { - await using tmp = await tmpdir() - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(read("../outside.txt")).rejects.toThrow("Access denied") - }, - }) - }) + it.instance("throws for paths outside project directory", () => + Effect.gen(function* () { + expect(yield* failureMessage(read("../outside.txt"))).toContain("Access denied") + }), + ) }) describe("status()", () => { - test("detects modified file", async () => { - await using tmp = await tmpdir({ git: true }) - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "original\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(filepath, "modified\nextra line\n", "utf-8") + it.instance( + "detects modified file", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "original\n", "utf-8")) + yield* gitAddAll(test.directory) + yield* gitCommit(test.directory, "add file") + yield* Effect.promise(() => fs.writeFile(filepath, "modified\nextra line\n", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await status() - const entry = result.find((f) => f.path === "file.txt") + const result = yield* status() + const entry = result.find((file) => file.path === "file.txt") expect(entry).toBeDefined() expect(entry!.status).toBe("modified") expect(entry!.added).toBeGreaterThan(0) expect(entry!.removed).toBeGreaterThan(0) - }, - }) - }) + }), + { git: true }, + ) - test("detects untracked file as added", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8") + it.instance( + "detects untracked file as added", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await status() - const entry = result.find((f) => f.path === "new.txt") + const result = yield* status() + const entry = result.find((file) => file.path === "new.txt") expect(entry).toBeDefined() expect(entry!.status).toBe("added") - expect(entry!.added).toBe(4) // 3 lines + trailing newline splits to 4 + expect(entry!.added).toBe(4) expect(entry!.removed).toBe(0) - }, - }) - }) + }), + { git: true }, + ) - test("detects deleted file", async () => { - await using tmp = await tmpdir({ git: true }) - const filepath = path.join(tmp.path, "gone.txt") - await fs.writeFile(filepath, "content\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit -m "add file"`.cwd(tmp.path).quiet() - await fs.rm(filepath) + it.instance( + "detects deleted file", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "gone.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "content\n", "utf-8")) + yield* gitAddAll(test.directory) + yield* gitCommit(test.directory, "add file") + yield* Effect.promise(() => fs.rm(filepath)) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await status() - // Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted") - const entries = result.filter((f) => f.path === "gone.txt") - expect(entries.some((e) => e.status === "deleted")).toBe(true) - }, - }) - }) + const result = yield* status() + const entries = result.filter((file) => file.path === "gone.txt") + expect(entries.some((entry) => entry.status === "deleted")).toBe(true) + }), + { git: true }, + ) - test("detects mixed changes", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "keep.txt"), "keep\n", "utf-8") - await fs.writeFile(path.join(tmp.path, "remove.txt"), "remove\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit -m "initial"`.cwd(tmp.path).quiet() + it.instance( + "detects mixed changes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "keep\n", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "remove.txt"), "remove\n", "utf-8")) + yield* gitAddAll(test.directory) + yield* gitCommit(test.directory, "initial") - // Modify one, delete one, add one - await fs.writeFile(path.join(tmp.path, "keep.txt"), "changed\n", "utf-8") - await fs.rm(path.join(tmp.path, "remove.txt")) - await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8") + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "changed\n", "utf-8")) + yield* Effect.promise(() => fs.rm(path.join(test.directory, "remove.txt"))) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "brand-new.txt"), "hello\n", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await status() - expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true) - expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true) - expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true) - }, - }) - }) + const result = yield* status() + expect(result.some((file) => file.path === "keep.txt" && file.status === "modified")).toBe(true) + expect(result.some((file) => file.path === "remove.txt" && file.status === "deleted")).toBe(true) + expect(result.some((file) => file.path === "brand-new.txt" && file.status === "added")).toBe(true) + }), + { git: true }, + ) - test("returns empty for non-git project", async () => { - await using tmp = await tmpdir() + it.instance("returns empty for non-git project", () => + Effect.gen(function* () { + expect(yield* status()).toEqual([]) + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await status() - expect(result).toEqual([]) - }, - }) - }) + it.instance( + "returns empty for clean repo", + () => + Effect.gen(function* () { + expect(yield* status()).toEqual([]) + }), + { git: true }, + ) - test("returns empty for clean repo", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance( + "parses binary numstat as 0", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "data.bin") + yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from(Array.from({ length: 256 }, (_, index) => index)))) + yield* gitAddAll(test.directory) + yield* gitCommit(test.directory, "add binary") + yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256)))) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await status() - expect(result).toEqual([]) - }, - }) - }) - - test("parses binary numstat as 0", async () => { - await using tmp = await tmpdir({ git: true }) - const filepath = path.join(tmp.path, "data.bin") - // Write content with null bytes so git treats it as binary - const binaryData = Buffer.alloc(256) - for (let i = 0; i < 256; i++) binaryData[i] = i - await fs.writeFile(filepath, binaryData) - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit -m "add binary"`.cwd(tmp.path).quiet() - // Modify the binary - const modified = Buffer.alloc(512) - for (let i = 0; i < 512; i++) modified[i] = i % 256 - await fs.writeFile(filepath, modified) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await status() - const entry = result.find((f) => f.path === "data.bin") + const result = yield* status() + const entry = result.find((file) => file.path === "data.bin") expect(entry).toBeDefined() expect(entry!.status).toBe("modified") expect(entry!.added).toBe(0) expect(entry!.removed).toBe(0) - }, - }) - }) + }), + { git: true }, + ) }) describe("list()", () => { - test("returns files and directories with correct shape", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.mkdir(path.join(tmp.path, "subdir")) - await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8") - await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8") + it.instance( + "returns files and directories with correct shape", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "subdir"))) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "content", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "subdir", "nested.txt"), "nested", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nodes = await list() + const nodes = yield* list() expect(nodes.length).toBeGreaterThanOrEqual(2) for (const node of nodes) { expect(node).toHaveProperty("name") @@ -561,289 +493,260 @@ describe("file/index Filesystem patterns", () => { expect(node).toHaveProperty("ignored") expect(["file", "directory"]).toContain(node.type) } - }, - }) - }) + }), + { git: true }, + ) - test("sorts directories before files, alphabetical within each", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.mkdir(path.join(tmp.path, "beta")) - await fs.mkdir(path.join(tmp.path, "alpha")) - await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8") - await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8") + it.instance( + "sorts directories before files, alphabetical within each", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "beta"))) + yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "alpha"))) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "zz.txt"), "", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "aa.txt"), "", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nodes = await list() - const dirs = nodes.filter((n) => n.type === "directory") - const files = nodes.filter((n) => n.type === "file") - // Dirs come first - const firstFile = nodes.findIndex((n) => n.type === "file") - const lastDir = nodes.findLastIndex((n) => n.type === "directory") + const nodes = yield* list() + const dirs = nodes.filter((node) => node.type === "directory") + const files = nodes.filter((node) => node.type === "file") + const firstFile = nodes.findIndex((node) => node.type === "file") + const lastDir = nodes.findLastIndex((node) => node.type === "directory") if (lastDir >= 0 && firstFile >= 0) { expect(lastDir).toBeLessThan(firstFile) } - // Alphabetical within dirs - expect(dirs.map((d) => d.name)).toEqual(dirs.map((d) => d.name).toSorted()) - // Alphabetical within files - expect(files.map((f) => f.name)).toEqual(files.map((f) => f.name).toSorted()) - }, - }) - }) + expect(dirs.map((dir) => dir.name)).toEqual(dirs.map((dir) => dir.name).toSorted()) + expect(files.map((file) => file.name)).toEqual(files.map((file) => file.name).toSorted()) + }), + { git: true }, + ) - test("excludes .git and .DS_Store", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8") - await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8") + it.instance( + "excludes .git and .DS_Store", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".DS_Store"), "", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "visible.txt"), "", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nodes = await list() - const names = nodes.map((n) => n.name) + const names = (yield* list()).map((node) => node.name) expect(names).not.toContain(".git") expect(names).not.toContain(".DS_Store") expect(names).toContain("visible.txt") - }, - }) - }) + }), + { git: true }, + ) - test("marks gitignored files as ignored", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, ".gitignore"), "*.log\nbuild/\n", "utf-8") - await fs.writeFile(path.join(tmp.path, "app.log"), "log data", "utf-8") - await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8") - await fs.mkdir(path.join(tmp.path, "build")) + it.instance( + "marks gitignored files as ignored", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".gitignore"), "*.log\nbuild/\n", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "app.log"), "log data", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "main.ts"), "code", "utf-8")) + yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "build"))) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nodes = await list() - const logNode = nodes.find((n) => n.name === "app.log") - const tsNode = nodes.find((n) => n.name === "main.ts") - const buildNode = nodes.find((n) => n.name === "build") - expect(logNode?.ignored).toBe(true) - expect(tsNode?.ignored).toBe(false) - expect(buildNode?.ignored).toBe(true) - }, - }) - }) + const nodes = yield* list() + expect(nodes.find((node) => node.name === "app.log")?.ignored).toBe(true) + expect(nodes.find((node) => node.name === "main.ts")?.ignored).toBe(false) + expect(nodes.find((node) => node.name === "build")?.ignored).toBe(true) + }), + { git: true }, + ) - test("lists subdirectory contents", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.mkdir(path.join(tmp.path, "sub")) - await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8") - await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8") + it.instance( + "lists subdirectory contents", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "sub"))) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "a.txt"), "", "utf-8")) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "b.txt"), "", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nodes = await list("sub") + const nodes = yield* list("sub") expect(nodes.length).toBe(2) - expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"]) - // Paths should be relative to project root (normalize for Windows) + expect(nodes.map((node) => node.name).sort()).toEqual(["a.txt", "b.txt"]) expect(nodes[0].path.replaceAll("\\", "/").startsWith("sub/")).toBe(true) - }, - }) - }) + }), + { git: true }, + ) - test("throws for paths outside project directory", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance( + "throws for paths outside project directory", + () => + Effect.gen(function* () { + expect(yield* failureMessage(list("../outside"))).toContain("Access denied") + }), + { git: true }, + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await expect(list("../outside")).rejects.toThrow("Access denied") - }, - }) - }) + it.instance("works without git", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "hi", "utf-8")) - test("works without git", async () => { - await using tmp = await tmpdir() - await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8") - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const nodes = await list() - expect(nodes.length).toBeGreaterThanOrEqual(1) - // Without git, ignored should be false for all - for (const node of nodes) { - expect(node.ignored).toBe(false) - } - }, - }) - }) + const nodes = yield* list() + expect(nodes.length).toBeGreaterThanOrEqual(1) + for (const node of nodes) { + expect(node.ignored).toBe(false) + } + }), + ) }) describe("search()", () => { - async function setupSearchableRepo() { - const tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8") - await fs.writeFile(path.join(tmp.path, "utils.ts"), "utils", "utf-8") - await fs.writeFile(path.join(tmp.path, "readme.md"), "readme", "utf-8") - await fs.mkdir(path.join(tmp.path, "src")) - await fs.mkdir(path.join(tmp.path, ".hidden")) - await fs.writeFile(path.join(tmp.path, "src", "main.ts"), "main", "utf-8") - await fs.writeFile(path.join(tmp.path, ".hidden", "secret.ts"), "secret", "utf-8") - return tmp - } + it.instance( + "empty query returns files", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() - test("empty query returns files", async () => { - await using tmp = await setupSearchableRepo() - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - - const result = await search({ query: "", type: "file" }) + const result = yield* search({ query: "", type: "file" }) expect(result.length).toBeGreaterThan(0) - }, - }) - }) + }), + { git: true }, + ) - test("search works before explicit init", async () => { - await using tmp = await setupSearchableRepo() + it.instance( + "search works before explicit init", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await search({ query: "main", type: "file" }) - expect(result.some((f) => f.includes("main"))).toBe(true) - }, - }) - }) + const result = yield* search({ query: "main", type: "file" }) + expect(result.some((file) => file.includes("main"))).toBe(true) + }), + { git: true }, + ) - test("empty query returns dirs sorted with hidden last", async () => { - await using tmp = await setupSearchableRepo() + it.instance( + "empty query returns dirs sorted with hidden last", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - - const result = await search({ query: "", type: "directory" }) + const result = yield* search({ query: "", type: "directory" }) expect(result.length).toBeGreaterThan(0) - // Find first hidden dir index - const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1)) - const lastVisible = result.findLastIndex((d) => !d.split("/").some((p) => p.startsWith(".") && p.length > 1)) + const firstHidden = result.findIndex((dir) => dir.split("/").some((part) => part.startsWith(".") && part.length > 1)) + const lastVisible = result.findLastIndex((dir) => !dir.split("/").some((part) => part.startsWith(".") && part.length > 1)) if (firstHidden >= 0 && lastVisible >= 0) { expect(firstHidden).toBeGreaterThan(lastVisible) } - }, - }) - }) + }), + { git: true }, + ) - test("fuzzy matches file names", async () => { - await using tmp = await setupSearchableRepo() + it.instance( + "fuzzy matches file names", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() + const result = yield* search({ query: "main", type: "file" }) + expect(result.some((file) => file.includes("main"))).toBe(true) + }), + { git: true }, + ) - const result = await search({ query: "main", type: "file" }) - expect(result.some((f) => f.includes("main"))).toBe(true) - }, - }) - }) + it.instance( + "type filter returns only files", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() - test("type filter returns only files", async () => { - await using tmp = await setupSearchableRepo() - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - - const result = await search({ query: "", type: "file" }) - // Files don't end with / - for (const f of result) { - expect(f.endsWith("/")).toBe(false) + const result = yield* search({ query: "", type: "file" }) + for (const file of result) { + expect(file.endsWith("/")).toBe(false) } - }, - }) - }) + }), + { git: true }, + ) - test("type filter returns only directories", async () => { - await using tmp = await setupSearchableRepo() + it.instance( + "type filter returns only directories", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - - const result = await search({ query: "", type: "directory" }) - // Directories end with / - for (const d of result) { - expect(d.endsWith("/")).toBe(true) + const result = yield* search({ query: "", type: "directory" }) + for (const dir of result) { + expect(dir.endsWith("/")).toBe(true) } - }, - }) - }) + }), + { git: true }, + ) - test("respects limit", async () => { - await using tmp = await setupSearchableRepo() + it.instance( + "respects limit", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - - const result = await search({ query: "", type: "file", limit: 2 }) + const result = yield* search({ query: "", type: "file", limit: 2 }) expect(result.length).toBeLessThanOrEqual(2) - }, - }) - }) + }), + { git: true }, + ) - test("query starting with dot prefers hidden files", async () => { - await using tmp = await setupSearchableRepo() + it.instance( + "query starting with dot prefers hidden files", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - - const result = await search({ query: ".hidden", type: "directory" }) + const result = yield* search({ query: ".hidden", type: "directory" }) expect(result.length).toBeGreaterThan(0) expect(result[0]).toContain(".hidden") - }, - }) - }) + }), + { git: true }, + ) - test("search refreshes after init when files change", async () => { - await using tmp = await setupSearchableRepo() + it.instance( + "search refreshes after init when files change", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* setupSearchableRepo(test.directory) + yield* init() + expect(yield* search({ query: "fresh", type: "file" })).toEqual([]) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - expect(await search({ query: "fresh", type: "file" })).toEqual([]) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "fresh.ts"), "fresh", "utf-8")) - await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8") - - const result = await search({ query: "fresh", type: "file" }) - expect(result).toContain("fresh.ts") - }, - }) - }) + expect(yield* search({ query: "fresh", type: "file" })).toContain("fresh.ts") + }), + { git: true }, + ) }) describe("read() - diff/patch", () => { - test("returns diff and patch for modified tracked file", async () => { - await using tmp = await tmpdir({ git: true }) - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "original content\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(filepath, "modified content\n", "utf-8") + it.instance( + "returns diff and patch for modified tracked file", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "original content\n", "utf-8")) + yield* gitAddAll(test.directory) + yield* gitCommit(test.directory, "add file") + yield* Effect.promise(() => fs.writeFile(filepath, "modified content\n", "utf-8")) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("file.txt") + const result = yield* read("file.txt") expect(result.type).toBe("text") expect(result.content).toBe("modified content") expect(result.diff).toBeDefined() @@ -851,107 +754,90 @@ describe("file/index Filesystem patterns", () => { expect(result.diff).toContain("modified content") expect(result.patch).toBeDefined() expect(result.patch!.hunks.length).toBeGreaterThan(0) - }, - }) - }) + }), + { git: true }, + ) - test("returns diff for staged changes", async () => { - await using tmp = await tmpdir({ git: true }) - const filepath = path.join(tmp.path, "staged.txt") - await fs.writeFile(filepath, "before\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(filepath, "after\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() + it.instance( + "returns diff for staged changes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "staged.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "before\n", "utf-8")) + yield* gitAddAll(test.directory) + yield* gitCommit(test.directory, "add file") + yield* Effect.promise(() => fs.writeFile(filepath, "after\n", "utf-8")) + yield* gitAddAll(test.directory) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("staged.txt") + const result = yield* read("staged.txt") expect(result.diff).toBeDefined() expect(result.patch).toBeDefined() - }, - }) - }) + }), + { git: true }, + ) - test("returns no diff for unmodified file", async () => { - await using tmp = await tmpdir({ git: true }) - const filepath = path.join(tmp.path, "clean.txt") - await fs.writeFile(filepath, "unchanged\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit -m "add file"`.cwd(tmp.path).quiet() + it.instance( + "returns no diff for unmodified file", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "clean.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "unchanged\n", "utf-8")) + yield* gitAddAll(test.directory) + yield* gitCommit(test.directory, "add file") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const result = await read("clean.txt") + const result = yield* read("clean.txt") expect(result.type).toBe("text") expect(result.content).toBe("unchanged") expect(result.diff).toBeUndefined() expect(result.patch).toBeUndefined() - }, - }) - }) + }), + { git: true }, + ) }) describe("InstanceState isolation", () => { - test("two directories get independent file caches", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) - await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8") - await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8") + it.instance( + "two directories get independent file caches", + () => + Effect.gen(function* () { + const one = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(one.directory, "a.ts"), "one", "utf-8")) + yield* init() + expect(yield* search({ query: "a.ts", type: "file" })).toContain("a.ts") + expect(yield* search({ query: "b.ts", type: "file" })).not.toContain("b.ts") - await WithInstance.provide({ - directory: one.path, - fn: async () => { - await init() - const results = await search({ query: "a.ts", type: "file" }) - expect(results).toContain("a.ts") - const results2 = await search({ query: "b.ts", type: "file" }) - expect(results2).not.toContain("b.ts") - }, - }) + yield* Effect.gen(function* () { + const two = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(two.directory, "b.ts"), "two", "utf-8")) + yield* init() + expect(yield* search({ query: "b.ts", type: "file" })).toContain("b.ts") + expect(yield* search({ query: "a.ts", type: "file" })).not.toContain("a.ts") + }).pipe(withTmpdirInstance({ git: true })) + }), + { git: true }, + ) - await WithInstance.provide({ - directory: two.path, - fn: async () => { - await init() - const results = await search({ query: "b.ts", type: "file" }) - expect(results).toContain("b.ts") - const results2 = await search({ query: "a.ts", type: "file" }) - expect(results2).not.toContain("a.ts") - }, - }) - }) + it.instance( + "disposal gives fresh state on next access", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "before.ts"), "before", "utf-8")) + yield* init() + expect(yield* search({ query: "before", type: "file" })).toContain("before.ts") - test("disposal gives fresh state on next access", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8") + yield* Effect.promise(() => disposeAllInstances()) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - const results = await search({ query: "before", type: "file" }) - expect(results).toContain("before.ts") - }, - }) + yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "after.ts"), "after", "utf-8")) + yield* Effect.promise(() => fs.rm(path.join(test.directory, "before.ts"))) - await disposeAllInstances() - - await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8") - await fs.rm(path.join(tmp.path, "before.ts")) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await init() - const results = await search({ query: "after", type: "file" }) - expect(results).toContain("after.ts") - const stale = await search({ query: "before", type: "file" }) - expect(stale).not.toContain("before.ts") - }, - }) - }) + yield* init() + expect(yield* search({ query: "after", type: "file" })).toContain("after.ts") + expect(yield* search({ query: "before", type: "file" })).not.toContain("before.ts") + }), + { git: true }, + ) }) }) From 44edb639c2c2b2874e7cdeb25e048ccad8be449c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 20:40:23 -0400 Subject: [PATCH 09/32] test(session): migrate message pagination to Effect runner (#26957) --- .../test/session/messages-pagination.test.ts | 1598 +++++++---------- 1 file changed, 678 insertions(+), 920 deletions(-) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 86e1d85d0d..49828a9b62 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -1,46 +1,41 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" -import path from "path" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import * as Log from "@opencode-ai/core/util/log" +import { testEffect } from "../lib/effect" -const root = path.join(__dirname, "../..") void Log.init({ print: false }) -function run(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) -} +const it = testEffect(SessionNs.defaultLayer) -const svc = { - ...SessionNs, - create(input?: SessionNs.CreateInput) { - return run(SessionNs.Service.use((svc) => svc.create(input))) - }, - remove(id: SessionID) { - return run(SessionNs.Service.use((svc) => svc.remove(id))) - }, - updateMessage(msg: T) { - return run(SessionNs.Service.use((svc) => svc.updateMessage(msg))) - }, - updatePart(part: T) { - return run(SessionNs.Service.use((svc) => svc.updatePart(part))) - }, - fork(input: { sessionID: SessionID; messageID?: MessageID }) { - return run(SessionNs.Service.use((svc) => svc.fork(input))) - }, -} +const withSession = ( + fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect, +) => + Effect.acquireUseRelease( + Effect.gen(function* () { + const session = yield* SessionNs.Service + const created = yield* session.create({}) + return { session, sessionID: created.id } + }), + fn, + (input) => input.session.remove(input.sessionID).pipe(Effect.ignore), + ) -async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) { +// Helper functions using Effect.gen +const fill = Effect.fn("Test.fill")(function* ( + sessionID: SessionID, + count: number, + time = (i: number) => Date.now() + i, +) { + const session = yield* SessionNs.Service const ids = [] as MessageID[] for (let i = 0; i < count; i++) { const id = MessageID.ascending() ids.push(id) - await svc.updateMessage({ + yield* session.updateMessage({ id, sessionID, role: "user", @@ -50,7 +45,7 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D tools: {}, mode: "", } as unknown as MessageV2.Info) - await svc.updatePart({ + yield* session.updatePart({ id: PartID.ascending(), sessionID, messageID: id, @@ -59,11 +54,12 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D }) } return ids -} +}) -async function addUser(sessionID: SessionID, text?: string) { +const addUser = Effect.fn("Test.addUser")(function* (sessionID: SessionID, text?: string) { + const session = yield* SessionNs.Service const id = MessageID.ascending() - await svc.updateMessage({ + yield* session.updateMessage({ id, sessionID, role: "user", @@ -74,7 +70,7 @@ async function addUser(sessionID: SessionID, text?: string) { mode: "", } as unknown as MessageV2.Info) if (text) { - await svc.updatePart({ + yield* session.updatePart({ id: PartID.ascending(), sessionID, messageID: id, @@ -83,15 +79,16 @@ async function addUser(sessionID: SessionID, text?: string) { }) } return id -} +}) -async function addAssistant( +const addAssistant = Effect.fn("Test.addAssistant")(function* ( sessionID: SessionID, parentID: MessageID, opts?: { summary?: boolean; finish?: string; error?: MessageV2.Assistant["error"] }, ) { + const session = yield* SessionNs.Service const id = MessageID.ascending() - await svc.updateMessage({ + yield* session.updateMessage({ id, sessionID, role: "assistant", @@ -109,10 +106,15 @@ async function addAssistant( error: opts?.error, } as unknown as MessageV2.Info) return id -} +}) -async function addCompactionPart(sessionID: SessionID, messageID: MessageID, tailStartID?: MessageID) { - await svc.updatePart({ +const addCompactionPart = Effect.fn("Test.addCompactionPart")(function* ( + sessionID: SessionID, + messageID: MessageID, + tailStartID?: MessageID, +) { + const session = yield* SessionNs.Service + yield* session.updatePart({ id: PartID.ascending(), sessionID, messageID, @@ -120,933 +122,713 @@ async function addCompactionPart(sessionID: SessionID, messageID: MessageID, tai auto: true, tail_start_id: tailStartID, } as any) -} +}) describe("MessageV2.page", () => { - test("returns sync result", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 2) + it.instance("returns sync result", () => + withSession(({ sessionID }) => Effect.gen(function* () { + yield* fill(sessionID, 2) - const result = MessageV2.page({ sessionID: session.id, limit: 10 }) - expect(result).toBeDefined() - expect(result.items).toBeArray() + const result = MessageV2.page({ sessionID, limit: 10 }) + expect(result).toBeDefined() + expect(result.items).toBeArray() + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("pages backward with opaque cursors", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 6) - test("pages backward with opaque cursors", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 6) + const a = MessageV2.page({ sessionID, limit: 2 }) + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) + expect(a.items.every((item) => item.parts.length === 1)).toBe(true) + expect(a.more).toBe(true) + expect(a.cursor).toBeTruthy() - const a = MessageV2.page({ sessionID: session.id, limit: 2 }) - expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) - expect(a.items.every((item) => item.parts.length === 1)).toBe(true) - expect(a.more).toBe(true) - expect(a.cursor).toBeTruthy() + const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) + expect(b.more).toBe(true) + expect(b.cursor).toBeTruthy() - const b = MessageV2.page({ sessionID: session.id, limit: 2, before: a.cursor! }) - expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) - expect(b.more).toBe(true) - expect(b.cursor).toBeTruthy() + const c = MessageV2.page({ sessionID, limit: 2, before: b.cursor! }) + expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + expect(c.more).toBe(false) + expect(c.cursor).toBeUndefined() + })), + ) - const c = MessageV2.page({ sessionID: session.id, limit: 2, before: b.cursor! }) - expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) - expect(c.more).toBe(false) - expect(c.cursor).toBeUndefined() + it.instance("returns items in chronological order within a page", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 4) - await svc.remove(session.id) - }, - }) - }) + const result = MessageV2.page({ sessionID, limit: 4 }) + expect(result.items.map((item) => item.info.id)).toEqual(ids) + })), + ) - test("returns items in chronological order within a page", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 4) + it.instance("returns empty items for session with no messages", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const result = MessageV2.page({ sessionID, limit: 10 }) + expect(result.items).toEqual([]) + expect(result.more).toBe(false) + expect(result.cursor).toBeUndefined() + })), + ) - const result = MessageV2.page({ sessionID: session.id, limit: 4 }) - expect(result.items.map((item) => item.info.id)).toEqual(ids) + it.instance("throws NotFoundError for non-existent session", () => + Effect.gen(function* () { + const fake = "non-existent-session" as SessionID + expect(() => MessageV2.page({ sessionID: fake, limit: 10 })).toThrow("NotFoundError") + }), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("handles exact limit boundary", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 3) - test("returns empty items for session with no messages", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + const result = MessageV2.page({ sessionID, limit: 3 }) + expect(result.items.map((item) => item.info.id)).toEqual(ids) + expect(result.more).toBe(false) + expect(result.cursor).toBeUndefined() + })), + ) - const result = MessageV2.page({ sessionID: session.id, limit: 10 }) - expect(result.items).toEqual([]) - expect(result.more).toBe(false) - expect(result.cursor).toBeUndefined() + it.instance("limit of 1 returns single newest message", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 5) - await svc.remove(session.id) - }, - }) - }) + const result = MessageV2.page({ sessionID, limit: 1 }) + expect(result.items).toHaveLength(1) + expect(result.items[0].info.id).toBe(ids[ids.length - 1]) + expect(result.more).toBe(true) + })), + ) - test("throws NotFoundError for non-existent session", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const fake = "non-existent-session" as SessionID - expect(() => MessageV2.page({ sessionID: fake, limit: 10 })).toThrow("NotFoundError") - }, - }) - }) + it.instance("hydrates multiple parts per message", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - test("handles exact limit boundary", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 3) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "extra", + }) - const result = MessageV2.page({ sessionID: session.id, limit: 3 }) - expect(result.items.map((item) => item.info.id)).toEqual(ids) - expect(result.more).toBe(false) - expect(result.cursor).toBeUndefined() + const result = MessageV2.page({ sessionID, limit: 10 }) + expect(result.items).toHaveLength(1) + expect(result.items[0].parts).toHaveLength(2) + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("accepts cursors from fractional timestamps", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 4, (i: number) => 1000.5 + i) - test("limit of 1 returns single newest message", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 5) + const a = MessageV2.page({ sessionID, limit: 2 }) + const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) - const result = MessageV2.page({ sessionID: session.id, limit: 1 }) - expect(result.items).toHaveLength(1) - expect(result.items[0].info.id).toBe(ids[ids.length - 1]) - expect(result.more).toBe(true) + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("messages with same timestamp are ordered by id", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 4, () => 1000) - test("hydrates multiple parts per message", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const [id] = await fill(session.id, 1) + const a = MessageV2.page({ sessionID, limit: 2 }) + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) + expect(a.more).toBe(true) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: id, - type: "text", - text: "extra", - }) + const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + expect(b.more).toBe(false) + })), + ) - const result = MessageV2.page({ sessionID: session.id, limit: 10 }) - expect(result.items).toHaveLength(1) - expect(result.items[0].parts).toHaveLength(2) + it.instance("does not return messages from other sessions", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const a = yield* session.create({}) + const b = yield* session.create({}) + yield* fill(a.id, 3) + yield* fill(b.id, 2) - await svc.remove(session.id) - }, - }) - }) + const resultA = MessageV2.page({ sessionID: a.id, limit: 10 }) + const resultB = MessageV2.page({ sessionID: b.id, limit: 10 }) + expect(resultA.items).toHaveLength(3) + expect(resultB.items).toHaveLength(2) + expect(resultA.items.every((item) => item.info.sessionID === a.id)).toBe(true) + expect(resultB.items.every((item) => item.info.sessionID === b.id)).toBe(true) - test("accepts cursors from fractional timestamps", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 4, (i) => 1000.5 + i) + yield* session.remove(a.id) + yield* session.remove(b.id) + }), + ) - const a = MessageV2.page({ sessionID: session.id, limit: 2 }) - const b = MessageV2.page({ sessionID: session.id, limit: 2, before: a.cursor! }) + it.instance("large limit returns all messages without cursor", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 10) - expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) - expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) - - await svc.remove(session.id) - }, - }) - }) - - test("messages with same timestamp are ordered by id", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 4, () => 1000) - - const a = MessageV2.page({ sessionID: session.id, limit: 2 }) - expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) - expect(a.more).toBe(true) - - const b = MessageV2.page({ sessionID: session.id, limit: 2, before: a.cursor! }) - expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) - expect(b.more).toBe(false) - - await svc.remove(session.id) - }, - }) - }) - - test("does not return messages from other sessions", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const a = await svc.create({}) - const b = await svc.create({}) - await fill(a.id, 3) - await fill(b.id, 2) - - const resultA = MessageV2.page({ sessionID: a.id, limit: 10 }) - const resultB = MessageV2.page({ sessionID: b.id, limit: 10 }) - expect(resultA.items).toHaveLength(3) - expect(resultB.items).toHaveLength(2) - expect(resultA.items.every((item) => item.info.sessionID === a.id)).toBe(true) - expect(resultB.items.every((item) => item.info.sessionID === b.id)).toBe(true) - - await svc.remove(a.id) - await svc.remove(b.id) - }, - }) - }) - - test("large limit returns all messages without cursor", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 10) - - const result = MessageV2.page({ sessionID: session.id, limit: 100 }) - expect(result.items).toHaveLength(10) - expect(result.items.map((item) => item.info.id)).toEqual(ids) - expect(result.more).toBe(false) - expect(result.cursor).toBeUndefined() - - await svc.remove(session.id) - }, - }) - }) + const result = MessageV2.page({ sessionID, limit: 100 }) + expect(result.items).toHaveLength(10) + expect(result.items.map((item) => item.info.id)).toEqual(ids) + expect(result.more).toBe(false) + expect(result.cursor).toBeUndefined() + })), + ) }) describe("MessageV2.stream", () => { - test("yields items newest first", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 5) + it.instance("yields items newest first", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 5) - const items = Array.from(MessageV2.stream(session.id)) - expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse()) + const items = Array.from(MessageV2.stream(sessionID)) + expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse()) + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("yields nothing for empty session", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const items = Array.from(MessageV2.stream(sessionID)) + expect(items).toHaveLength(0) + })), + ) - test("yields nothing for empty session", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + it.instance("yields single message", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 1) - const items = Array.from(MessageV2.stream(session.id)) - expect(items).toHaveLength(0) + const items = Array.from(MessageV2.stream(sessionID)) + expect(items).toHaveLength(1) + expect(items[0].info.id).toBe(ids[0]) + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("hydrates parts for each yielded message", () => + withSession(({ sessionID }) => Effect.gen(function* () { + yield* fill(sessionID, 3) - test("yields single message", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 1) + const items = Array.from(MessageV2.stream(sessionID)) + for (const item of items) { + expect(item.parts).toHaveLength(1) + expect(item.parts[0].type).toBe("text") + } + })), + ) - const items = Array.from(MessageV2.stream(session.id)) - expect(items).toHaveLength(1) - expect(items[0].info.id).toBe(ids[0]) + it.instance("handles sets exceeding internal page size", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 60) - await svc.remove(session.id) - }, - }) - }) + const items = Array.from(MessageV2.stream(sessionID)) + expect(items).toHaveLength(60) + expect(items[0].info.id).toBe(ids[ids.length - 1]) + expect(items[59].info.id).toBe(ids[0]) + })), + ) - test("hydrates parts for each yielded message", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 3) + it.instance("is a sync generator", () => + withSession(({ sessionID }) => Effect.gen(function* () { + yield* fill(sessionID, 1) - const items = Array.from(MessageV2.stream(session.id)) - for (const item of items) { - expect(item.parts).toHaveLength(1) - expect(item.parts[0].type).toBe("text") - } - - await svc.remove(session.id) - }, - }) - }) - - test("handles sets exceeding internal page size", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 60) - - const items = Array.from(MessageV2.stream(session.id)) - expect(items).toHaveLength(60) - expect(items[0].info.id).toBe(ids[ids.length - 1]) - expect(items[59].info.id).toBe(ids[0]) - - await svc.remove(session.id) - }, - }) - }) - - test("is a sync generator", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 1) - - const gen = MessageV2.stream(session.id) - const first = gen.next() - // sync generator returns { value, done } directly, not a Promise - expect(first).toHaveProperty("value") - expect(first).toHaveProperty("done") - expect(first.done).toBe(false) - - await svc.remove(session.id) - }, - }) - }) + const gen = MessageV2.stream(sessionID) + const first = gen.next() + // sync generator returns { value, done } directly, not a Promise + expect(first).toHaveProperty("value") + expect(first).toHaveProperty("done") + expect(first.done).toBe(false) + })), + ) }) describe("MessageV2.parts", () => { - test("returns parts for a message", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const [id] = await fill(session.id, 1) + it.instance("returns parts for a message", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - const result = MessageV2.parts(id) - expect(result).toHaveLength(1) - expect(result[0].type).toBe("text") - expect((result[0] as MessageV2.TextPart).text).toBe("m0") + const result = MessageV2.parts(id) + expect(result).toHaveLength(1) + expect(result[0].type).toBe("text") + expect((result[0] as MessageV2.TextPart).text).toBe("m0") + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("returns empty array for message with no parts", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const id = yield* addUser(sessionID) - test("returns empty array for message with no parts", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const id = await addUser(session.id) + const result = MessageV2.parts(id) + expect(result).toEqual([]) + })), + ) - const result = MessageV2.parts(id) - expect(result).toEqual([]) + it.instance("returns multiple parts in order", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - await svc.remove(session.id) - }, - }) - }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "second", + }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "third", + }) - test("returns multiple parts in order", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const [id] = await fill(session.id, 1) + const result = MessageV2.parts(id) + expect(result).toHaveLength(3) + expect((result[0] as MessageV2.TextPart).text).toBe("m0") + expect((result[1] as MessageV2.TextPart).text).toBe("second") + expect((result[2] as MessageV2.TextPart).text).toBe("third") + })), + ) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: id, - type: "text", - text: "second", - }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: id, - type: "text", - text: "third", - }) + it.instance("returns empty for non-existent message id", () => + Effect.gen(function* () { + yield* SessionNs.Service + const result = MessageV2.parts(MessageID.ascending()) + expect(result).toEqual([]) + }), + ) - const result = MessageV2.parts(id) - expect(result).toHaveLength(3) - expect((result[0] as MessageV2.TextPart).text).toBe("m0") - expect((result[1] as MessageV2.TextPart).text).toBe("second") - expect((result[2] as MessageV2.TextPart).text).toBe("third") + it.instance("parts contain sessionID and messageID", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - await svc.remove(session.id) - }, - }) - }) - - test("returns empty for non-existent message id", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - await svc.create({}) - const result = MessageV2.parts(MessageID.ascending()) - expect(result).toEqual([]) - }, - }) - }) - - test("parts contain sessionID and messageID", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const [id] = await fill(session.id, 1) - - const result = MessageV2.parts(id) - expect(result[0].sessionID).toBe(session.id) - expect(result[0].messageID).toBe(id) - - await svc.remove(session.id) - }, - }) - }) + const result = MessageV2.parts(id) + expect(result[0].sessionID).toBe(sessionID) + expect(result[0].messageID).toBe(id) + })), + ) }) describe("MessageV2.get", () => { - test("returns message with hydrated parts", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const [id] = await fill(session.id, 1) + it.instance("returns message with hydrated parts", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - const result = MessageV2.get({ sessionID: session.id, messageID: id }) - expect(result.info.id).toBe(id) - expect(result.info.sessionID).toBe(session.id) - expect(result.info.role).toBe("user") - expect(result.parts).toHaveLength(1) - expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0") + const result = MessageV2.get({ sessionID, messageID: id }) + expect(result.info.id).toBe(id) + expect(result.info.sessionID).toBe(sessionID) + expect(result.info.role).toBe("user") + expect(result.parts).toHaveLength(1) + expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0") + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("throws NotFoundError for non-existent message", () => + withSession(({ sessionID }) => Effect.gen(function* () { + expect(() => MessageV2.get({ sessionID, messageID: MessageID.ascending() })).toThrow( + "NotFoundError", + ) + })), + ) - test("throws NotFoundError for non-existent message", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + it.instance("scopes by session id", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const a = yield* session.create({}) + const b = yield* session.create({}) + const [id] = yield* fill(a.id, 1) - expect(() => MessageV2.get({ sessionID: session.id, messageID: MessageID.ascending() })).toThrow( - "NotFoundError", - ) + expect(() => MessageV2.get({ sessionID: b.id, messageID: id })).toThrow("NotFoundError") + const result = MessageV2.get({ sessionID: a.id, messageID: id }) + expect(result.info.id).toBe(id) - await svc.remove(session.id) - }, - }) - }) + yield* session.remove(a.id) + yield* session.remove(b.id) + }), + ) - test("scopes by session id", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const a = await svc.create({}) - const b = await svc.create({}) - const [id] = await fill(a.id, 1) + it.instance("returns message with multiple parts", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - expect(() => MessageV2.get({ sessionID: b.id, messageID: id })).toThrow("NotFoundError") - const result = MessageV2.get({ sessionID: a.id, messageID: id }) - expect(result.info.id).toBe(id) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "extra", + }) - await svc.remove(a.id) - await svc.remove(b.id) - }, - }) - }) + const result = MessageV2.get({ sessionID, messageID: id }) + expect(result.parts).toHaveLength(2) + })), + ) - test("returns message with multiple parts", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const [id] = await fill(session.id, 1) + it.instance("returns assistant message with correct role", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + const uid = yield* addUser(sessionID, "hello") + const aid = yield* addAssistant(sessionID, uid) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: id, - type: "text", - text: "extra", - }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: aid, + type: "text", + text: "response", + }) - const result = MessageV2.get({ sessionID: session.id, messageID: id }) - expect(result.parts).toHaveLength(2) + const result = MessageV2.get({ sessionID, messageID: aid }) + expect(result.info.role).toBe("assistant") + expect(result.parts).toHaveLength(1) + expect((result.parts[0] as MessageV2.TextPart).text).toBe("response") + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("returns message with zero parts", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const id = yield* addUser(sessionID) - test("returns assistant message with correct role", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const uid = await addUser(session.id, "hello") - const aid = await addAssistant(session.id, uid) - - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: aid, - type: "text", - text: "response", - }) - - const result = MessageV2.get({ sessionID: session.id, messageID: aid }) - expect(result.info.role).toBe("assistant") - expect(result.parts).toHaveLength(1) - expect((result.parts[0] as MessageV2.TextPart).text).toBe("response") - - await svc.remove(session.id) - }, - }) - }) - - test("returns message with zero parts", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const id = await addUser(session.id) - - const result = MessageV2.get({ sessionID: session.id, messageID: id }) - expect(result.info.id).toBe(id) - expect(result.parts).toEqual([]) - - await svc.remove(session.id) - }, - }) - }) + const result = MessageV2.get({ sessionID, messageID: id }) + expect(result.info.id).toBe(id) + expect(result.parts).toEqual([]) + })), + ) }) describe("MessageV2.filterCompacted", () => { - test("returns all messages when no compaction", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 5) + it.instance("returns all messages when no compaction", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const ids = yield* fill(sessionID, 5) - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result).toHaveLength(5) - // reversed from newest-first to chronological - expect(result.map((item) => item.info.id)).toEqual(ids) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + expect(result).toHaveLength(5) + // reversed from newest-first to chronological + expect(result.map((item) => item.info.id)).toEqual(ids) + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("stops at compaction boundary and returns chronological order", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + // Chronological: u1(+compaction part), a1(summary, parentID=u1), u2, a2 + // Stream (newest first): a2, u2, a1(adds u1 to completed), u1(in completed + compaction) -> break + const u1 = yield* addUser(sessionID, "first question") + const a1 = yield* addAssistant(sessionID, u1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "summary", + }) + yield* addCompactionPart(sessionID, u1) - test("stops at compaction boundary and returns chronological order", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + const u2 = yield* addUser(sessionID, "new question") + const a2 = yield* addAssistant(sessionID, u2) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "new response", + }) - // Chronological: u1(+compaction part), a1(summary, parentID=u1), u2, a2 - // Stream (newest first): a2, u2, a1(adds u1 to completed), u1(in completed + compaction) -> break - const u1 = await addUser(session.id, "first question") - const a1 = await addAssistant(session.id, u1, { summary: true, finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a1, - type: "text", - text: "summary", - }) - await addCompactionPart(session.id, u1) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + // Includes compaction boundary: u1, a1, u2, a2 + expect(result[0].info.id).toBe(u1) + expect(result.length).toBe(4) + })), + ) - const u2 = await addUser(session.id, "new question") - const a2 = await addAssistant(session.id, u2) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a2, - type: "text", - text: "new response", - }) - - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - // Includes compaction boundary: u1, a1, u2, a2 - expect(result[0].info.id).toBe(u1) - expect(result.length).toBe(4) - - await svc.remove(session.id) - }, - }) - }) - - test("handles empty iterable", () => { + it.live("handles empty iterable", () => Effect.sync(() => { const result = MessageV2.filterCompacted([]) expect(result).toEqual([]) - }) + })) - test("does not break on compaction part without matching summary", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + it.instance("does not break on compaction part without matching summary", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "hello") + yield* addCompactionPart(sessionID, u1) + yield* addUser(sessionID, "world") - const u1 = await addUser(session.id, "hello") - await addCompactionPart(session.id, u1) - await addUser(session.id, "world") + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + expect(result).toHaveLength(2) + })), + ) - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result).toHaveLength(2) + it.instance("skips assistant with error even if marked as summary", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "hello") + yield* addCompactionPart(sessionID, u1) - await svc.remove(session.id) - }, - }) - }) + const error = new MessageV2.APIError({ + message: "boom", + isRetryable: true, + }).toObject() as MessageV2.Assistant["error"] + yield* addAssistant(sessionID, u1, { summary: true, finish: "end_turn", error }) + yield* addUser(sessionID, "retry") - test("skips assistant with error even if marked as summary", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + // Error assistant doesn't add to completed, so compaction boundary never triggers + expect(result).toHaveLength(3) + })), + ) - const u1 = await addUser(session.id, "hello") - await addCompactionPart(session.id, u1) + it.instance("skips assistant without finish even if marked as summary", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "hello") + yield* addCompactionPart(sessionID, u1) - const error = new MessageV2.APIError({ - message: "boom", - isRetryable: true, - }).toObject() as MessageV2.Assistant["error"] - await addAssistant(session.id, u1, { summary: true, finish: "end_turn", error }) - await addUser(session.id, "retry") + // summary=true but no finish + yield* addAssistant(sessionID, u1, { summary: true }) + yield* addUser(sessionID, "next") - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - // Error assistant doesn't add to completed, so compaction boundary never triggers - expect(result).toHaveLength(3) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + expect(result).toHaveLength(3) + })), + ) - await svc.remove(session.id) - }, - }) - }) + it.instance("ignores original tail when compaction stores tail_start_id", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "first") + const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "first reply", + }) - test("skips assistant without finish even if marked as summary", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + const u2 = yield* addUser(sessionID, "second") + const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "second reply", + }) - const u1 = await addUser(session.id, "hello") - await addCompactionPart(session.id, u1) + const c1 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c1, u2) + const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s1, + type: "text", + text: "summary", + }) - // summary=true but no finish - await addAssistant(session.id, u1, { summary: true }) - await addUser(session.id, "next") + const u3 = yield* addUser(sessionID, "third") + const a3 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a3, + type: "text", + text: "third reply", + }) - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result).toHaveLength(3) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - await svc.remove(session.id) - }, - }) - }) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) + })), + ) - test("ignores original tail when compaction stores tail_start_id", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + it.instance("fork keeps legacy tail_start_id without replaying the tail", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const created = yield* session.create({}) - const u1 = await addUser(session.id, "first") - const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a1, - type: "text", - text: "first reply", - }) + const u1 = yield* addUser(created.id, "first") + const a1 = yield* addAssistant(created.id, u1, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID: created.id, + messageID: a1, + type: "text", + text: "first reply", + }) - const u2 = await addUser(session.id, "second") - const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a2, - type: "text", - text: "second reply", - }) + const u2 = yield* addUser(created.id, "second") + const a2 = yield* addAssistant(created.id, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID: created.id, + messageID: a2, + type: "text", + text: "second reply", + }) - const c1 = await addUser(session.id) - await addCompactionPart(session.id, c1, u2) - const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: s1, - type: "text", - text: "summary", - }) + const c1 = yield* addUser(created.id) + yield* addCompactionPart(created.id, c1, u2) + const s1 = yield* addAssistant(created.id, c1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID: created.id, + messageID: s1, + type: "text", + text: "summary", + }) - const u3 = await addUser(session.id, "third") - const a3 = await addAssistant(session.id, u3, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a3, - type: "text", - text: "third reply", - }) + const u3 = yield* addUser(created.id, "third") + const a3 = yield* addAssistant(created.id, u3, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID: created.id, + messageID: a3, + type: "text", + text: "third reply", + }) - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) + const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(created.id)) + expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) + const forked = yield* session.fork({ sessionID: created.id }) + const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id)) + expect(childFiltered).toHaveLength(parentFiltered.length) - await svc.remove(session.id) - }, - }) - }) + const tailPart = childFiltered.flatMap((m) => m.parts).find((p) => p.type === "compaction") + expect(tailPart?.type).toBe("compaction") + if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part") + expect(tailPart.tail_start_id).toBeDefined() + expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(false) - test("fork keeps legacy tail_start_id without replaying the tail", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + yield* session.remove(forked.id) + yield* session.remove(created.id) + }), + ) - const u1 = await addUser(session.id, "first") - const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a1, - type: "text", - text: "first reply", - }) + it.instance("does not replay an assistant tail when compaction starts inside a turn", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "first") + const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "first reply", + }) - const u2 = await addUser(session.id, "second") - const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a2, - type: "text", - text: "second reply", - }) + const u2 = yield* addUser(sessionID, "second") + const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "second reply", + }) + const a3 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a3, + type: "text", + text: "tail reply", + }) - const c1 = await addUser(session.id) - await addCompactionPart(session.id, c1, u2) - const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: s1, - type: "text", - text: "summary", - }) + const c1 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c1, a3) + const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s1, + type: "text", + text: "summary", + }) - const u3 = await addUser(session.id, "third") - const a3 = await addAssistant(session.id, u3, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a3, - type: "text", - text: "third reply", - }) + const u3 = yield* addUser(sessionID, "third") + const a4 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a4, + type: "text", + text: "third reply", + }) - const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - const forked = await svc.fork({ sessionID: session.id }) - const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id)) - expect(childFiltered).toHaveLength(parentFiltered.length) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4]) + })), + ) - const tailPart = childFiltered.flatMap((m) => m.parts).find((p) => p.type === "compaction") - expect(tailPart?.type).toBe("compaction") - if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part") - expect(tailPart.tail_start_id).toBeDefined() - expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(false) + it.instance("prefers latest compaction boundary when repeated compactions exist", () => + withSession(({ session, sessionID }) => Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "first") + const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "first reply", + }) - await svc.remove(forked.id) - await svc.remove(session.id) - }, - }) - }) + const u2 = yield* addUser(sessionID, "second") + const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "second reply", + }) - test("does not replay an assistant tail when compaction starts inside a turn", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) + const c1 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c1, u2) + const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s1, + type: "text", + text: "summary one", + }) - const u1 = await addUser(session.id, "first") - const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a1, - type: "text", - text: "first reply", - }) + const u3 = yield* addUser(sessionID, "third") + const a3 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a3, + type: "text", + text: "third reply", + }) - const u2 = await addUser(session.id, "second") - const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a2, - type: "text", - text: "second reply", - }) - const a3 = await addAssistant(session.id, u2, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a3, - type: "text", - text: "tail reply", - }) + const c2 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c2, u3) + const s2 = yield* addAssistant(sessionID, c2, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s2, + type: "text", + text: "summary two", + }) - const c1 = await addUser(session.id) - await addCompactionPart(session.id, c1, a3) - const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: s1, - type: "text", - text: "summary", - }) + const u4 = yield* addUser(sessionID, "fourth") + const a4 = yield* addAssistant(sessionID, u4, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a4, + type: "text", + text: "fourth reply", + }) - const u3 = await addUser(session.id, "third") - const a4 = await addAssistant(session.id, u3, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a4, - type: "text", - text: "third reply", - }) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4]) - - await svc.remove(session.id) - }, - }) - }) - - test("prefers latest compaction boundary when repeated compactions exist", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - - const u1 = await addUser(session.id, "first") - const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a1, - type: "text", - text: "first reply", - }) - - const u2 = await addUser(session.id, "second") - const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a2, - type: "text", - text: "second reply", - }) - - const c1 = await addUser(session.id) - await addCompactionPart(session.id, c1, u2) - const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: s1, - type: "text", - text: "summary one", - }) - - const u3 = await addUser(session.id, "third") - const a3 = await addAssistant(session.id, u3, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a3, - type: "text", - text: "third reply", - }) - - const c2 = await addUser(session.id) - await addCompactionPart(session.id, c2, u3) - const s2 = await addAssistant(session.id, c2, { summary: true, finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: s2, - type: "text", - text: "summary two", - }) - - const u4 = await addUser(session.id, "fourth") - const a4 = await addAssistant(session.id, u4, { finish: "end_turn" }) - await svc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: a4, - type: "text", - text: "fourth reply", - }) - - const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - - expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4]) - - await svc.remove(session.id) - }, - }) - }) + expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4]) + })), + ) test("works with array input", () => { // filterCompacted accepts any Iterable, not just generators @@ -1093,82 +875,58 @@ describe("MessageV2.cursor", () => { }) describe("MessageV2 consistency", () => { - test("page hydration matches get for each message", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 3) + it.instance("page hydration matches get for each message", () => + withSession(({ sessionID }) => Effect.gen(function* () { + yield* fill(sessionID, 3) - const paged = MessageV2.page({ sessionID: session.id, limit: 10 }) - for (const item of paged.items) { - const got = MessageV2.get({ sessionID: session.id, messageID: item.info.id as MessageID }) - expect(got.info).toEqual(item.info) - expect(got.parts).toEqual(item.parts) + const paged = MessageV2.page({ sessionID, limit: 10 }) + for (const item of paged.items) { + const got = MessageV2.get({ sessionID, messageID: item.info.id as MessageID }) + expect(got.info).toEqual(item.info) + expect(got.parts).toEqual(item.parts) + } + })), + ) + + it.instance("parts from get match standalone parts call", () => + withSession(({ sessionID }) => Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) + + const got = MessageV2.get({ sessionID, messageID: id }) + const standalone = MessageV2.parts(id) + expect(got.parts).toEqual(standalone) + })), + ) + + it.instance("stream collects same messages as exhaustive page iteration", () => + withSession(({ sessionID }) => Effect.gen(function* () { + yield* fill(sessionID, 7) + + const streamed = Array.from(MessageV2.stream(sessionID)) + + const paged = [] as MessageV2.WithParts[] + let cursor: string | undefined + while (true) { + const result = MessageV2.page({ sessionID, limit: 3, before: cursor }) + for (let i = result.items.length - 1; i >= 0; i--) { + paged.push(result.items[i]) } + if (!result.more || !result.cursor) break + cursor = result.cursor + } - await svc.remove(session.id) - }, - }) - }) + expect(streamed.map((m) => m.info.id)).toEqual(paged.map((m) => m.info.id)) + })), + ) - test("parts from get match standalone parts call", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const [id] = await fill(session.id, 1) + it.instance("filterCompacted of full stream returns same as Array.from when no compaction", () => + withSession(({ sessionID }) => Effect.gen(function* () { + yield* fill(sessionID, 4) - const got = MessageV2.get({ sessionID: session.id, messageID: id }) - const standalone = MessageV2.parts(id) - expect(got.parts).toEqual(standalone) + const filtered = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const all = Array.from(MessageV2.stream(sessionID)).reverse() - await svc.remove(session.id) - }, - }) - }) - - test("stream collects same messages as exhaustive page iteration", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 7) - - const streamed = Array.from(MessageV2.stream(session.id)) - - const paged = [] as MessageV2.WithParts[] - let cursor: string | undefined - while (true) { - const result = MessageV2.page({ sessionID: session.id, limit: 3, before: cursor }) - for (let i = result.items.length - 1; i >= 0; i--) { - paged.push(result.items[i]) - } - if (!result.more || !result.cursor) break - cursor = result.cursor - } - - expect(streamed.map((m) => m.info.id)).toEqual(paged.map((m) => m.info.id)) - - await svc.remove(session.id) - }, - }) - }) - - test("filterCompacted of full stream returns same as Array.from when no compaction", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 4) - - const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - const all = Array.from(MessageV2.stream(session.id)).reverse() - - expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id)) - - await svc.remove(session.id) - }, - }) - }) + expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id)) + })), + ) }) From e0e9414cbdc32d3bd08c8c4ea239fb9e80091c8b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 00:41:30 +0000 Subject: [PATCH 10/32] chore: generate --- packages/opencode/test/file/index.test.ts | 56 +- .../test/session/messages-pagination.test.ts | 998 ++++++++++-------- 2 files changed, 578 insertions(+), 476 deletions(-) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 9250841404..8b48fff5e4 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -115,7 +115,9 @@ describe("file/index Filesystem patterns", () => { it.instance("handles multi-line text files", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "multiline.txt"), "line1\nline2\nline3", "utf-8")) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "multiline.txt"), "line1\nline2\nline3", "utf-8"), + ) const result = yield* read("multiline.txt") expect(result.content).toBe("line1\nline2\nline3") @@ -141,7 +143,9 @@ describe("file/index Filesystem patterns", () => { it.instance("returns empty for binary non-image files", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "binary.so"), Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "binary.so"), Buffer.from([0x7f, 0x45, 0x4c, 0x46])), + ) const result = yield* read("binary.so") expect(result.type).toBe("binary") @@ -250,7 +254,9 @@ describe("file/index Filesystem patterns", () => { yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "readonly.txt"), "content", "utf-8")) const nonExistentPath = path.join(test.directory, "does-not-exist.txt") - expect(Exit.isFailure(yield* Effect.promise(() => Filesystem.readText(nonExistentPath)).pipe(Effect.exit))).toBe(true) + expect( + Exit.isFailure(yield* Effect.promise(() => Filesystem.readText(nonExistentPath)).pipe(Effect.exit)), + ).toBe(true) const result = yield* read("does-not-exist.txt") expect(result.content).toBe("") @@ -261,7 +267,9 @@ describe("file/index Filesystem patterns", () => { Effect.gen(function* () { const test = yield* TestInstance const nonExistentPath = path.join(test.directory, "does-not-exist.bin") - const buffer = yield* Effect.promise(() => Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0))) + const buffer = yield* Effect.promise(() => + Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0)), + ) expect(buffer.byteLength).toBe(0) }), ) @@ -279,7 +287,9 @@ describe("file/index Filesystem patterns", () => { it.instance("treats .ts files as text", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.ts"), "export const value = 1", "utf-8")) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "test.ts"), "export const value = 1", "utf-8"), + ) const result = yield* read("test.ts") expect(result.type).toBe("text") @@ -290,7 +300,9 @@ describe("file/index Filesystem patterns", () => { it.instance("treats .mts files as text", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.mts"), "export const value = 1", "utf-8")) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "test.mts"), "export const value = 1", "utf-8"), + ) const result = yield* read("test.mts") expect(result.type).toBe("text") @@ -301,7 +313,9 @@ describe("file/index Filesystem patterns", () => { it.instance("treats .sh files as text", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.sh"), "#!/usr/bin/env bash\necho hello", "utf-8")) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "test.sh"), "#!/usr/bin/env bash\necho hello", "utf-8"), + ) const result = yield* read("test.sh") expect(result.type).toBe("text") @@ -334,7 +348,9 @@ describe("file/index Filesystem patterns", () => { it.instance("returns base64 encoding for images", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.jpg"), Buffer.from([0xff, 0xd8, 0xff, 0xe0]))) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "test.jpg"), Buffer.from([0xff, 0xd8, 0xff, 0xe0])), + ) const result = yield* read("test.jpg") expect(result.encoding).toBe("base64") @@ -384,7 +400,9 @@ describe("file/index Filesystem patterns", () => { () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8")) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8"), + ) const result = yield* status() const entry = result.find((file) => file.path === "new.txt") @@ -457,10 +475,14 @@ describe("file/index Filesystem patterns", () => { Effect.gen(function* () { const test = yield* TestInstance const filepath = path.join(test.directory, "data.bin") - yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from(Array.from({ length: 256 }, (_, index) => index)))) + yield* Effect.promise(() => + fs.writeFile(filepath, Buffer.from(Array.from({ length: 256 }, (_, index) => index))), + ) yield* gitAddAll(test.directory) yield* gitCommit(test.directory, "add binary") - yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256)))) + yield* Effect.promise(() => + fs.writeFile(filepath, Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256))), + ) const result = yield* status() const entry = result.find((file) => file.path === "data.bin") @@ -481,7 +503,9 @@ describe("file/index Filesystem patterns", () => { const test = yield* TestInstance yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "subdir"))) yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "content", "utf-8")) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "subdir", "nested.txt"), "nested", "utf-8")) + yield* Effect.promise(() => + fs.writeFile(path.join(test.directory, "subdir", "nested.txt"), "nested", "utf-8"), + ) const nodes = yield* list() expect(nodes.length).toBeGreaterThanOrEqual(2) @@ -633,8 +657,12 @@ describe("file/index Filesystem patterns", () => { const result = yield* search({ query: "", type: "directory" }) expect(result.length).toBeGreaterThan(0) - const firstHidden = result.findIndex((dir) => dir.split("/").some((part) => part.startsWith(".") && part.length > 1)) - const lastVisible = result.findLastIndex((dir) => !dir.split("/").some((part) => part.startsWith(".") && part.length > 1)) + const firstHidden = result.findIndex((dir) => + dir.split("/").some((part) => part.startsWith(".") && part.length > 1), + ) + const lastVisible = result.findLastIndex( + (dir) => !dir.split("/").some((part) => part.startsWith(".") && part.length > 1), + ) if (firstHidden >= 0 && lastVisible >= 0) { expect(firstHidden).toBeGreaterThan(lastVisible) } diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 49828a9b62..e1714a9015 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -126,53 +126,61 @@ const addCompactionPart = Effect.fn("Test.addCompactionPart")(function* ( describe("MessageV2.page", () => { it.instance("returns sync result", () => - withSession(({ sessionID }) => Effect.gen(function* () { - yield* fill(sessionID, 2) + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 2) - const result = MessageV2.page({ sessionID, limit: 10 }) - expect(result).toBeDefined() - expect(result.items).toBeArray() - })), + const result = MessageV2.page({ sessionID, limit: 10 }) + expect(result).toBeDefined() + expect(result.items).toBeArray() + }), + ), ) it.instance("pages backward with opaque cursors", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 6) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 6) - const a = MessageV2.page({ sessionID, limit: 2 }) - expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) - expect(a.items.every((item) => item.parts.length === 1)).toBe(true) - expect(a.more).toBe(true) - expect(a.cursor).toBeTruthy() + const a = MessageV2.page({ sessionID, limit: 2 }) + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) + expect(a.items.every((item) => item.parts.length === 1)).toBe(true) + expect(a.more).toBe(true) + expect(a.cursor).toBeTruthy() - const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) - expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) - expect(b.more).toBe(true) - expect(b.cursor).toBeTruthy() + const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) + expect(b.more).toBe(true) + expect(b.cursor).toBeTruthy() - const c = MessageV2.page({ sessionID, limit: 2, before: b.cursor! }) - expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) - expect(c.more).toBe(false) - expect(c.cursor).toBeUndefined() - })), + const c = MessageV2.page({ sessionID, limit: 2, before: b.cursor! }) + expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + expect(c.more).toBe(false) + expect(c.cursor).toBeUndefined() + }), + ), ) it.instance("returns items in chronological order within a page", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 4) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 4) - const result = MessageV2.page({ sessionID, limit: 4 }) - expect(result.items.map((item) => item.info.id)).toEqual(ids) - })), + const result = MessageV2.page({ sessionID, limit: 4 }) + expect(result.items.map((item) => item.info.id)).toEqual(ids) + }), + ), ) it.instance("returns empty items for session with no messages", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const result = MessageV2.page({ sessionID, limit: 10 }) - expect(result.items).toEqual([]) - expect(result.more).toBe(false) - expect(result.cursor).toBeUndefined() - })), + withSession(({ sessionID }) => + Effect.gen(function* () { + const result = MessageV2.page({ sessionID, limit: 10 }) + expect(result.items).toEqual([]) + expect(result.more).toBe(false) + expect(result.cursor).toBeUndefined() + }), + ), ) it.instance("throws NotFoundError for non-existent session", () => @@ -183,69 +191,79 @@ describe("MessageV2.page", () => { ) it.instance("handles exact limit boundary", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 3) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 3) - const result = MessageV2.page({ sessionID, limit: 3 }) - expect(result.items.map((item) => item.info.id)).toEqual(ids) - expect(result.more).toBe(false) - expect(result.cursor).toBeUndefined() - })), + const result = MessageV2.page({ sessionID, limit: 3 }) + expect(result.items.map((item) => item.info.id)).toEqual(ids) + expect(result.more).toBe(false) + expect(result.cursor).toBeUndefined() + }), + ), ) it.instance("limit of 1 returns single newest message", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 5) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 5) - const result = MessageV2.page({ sessionID, limit: 1 }) - expect(result.items).toHaveLength(1) - expect(result.items[0].info.id).toBe(ids[ids.length - 1]) - expect(result.more).toBe(true) - })), + const result = MessageV2.page({ sessionID, limit: 1 }) + expect(result.items).toHaveLength(1) + expect(result.items[0].info.id).toBe(ids[ids.length - 1]) + expect(result.more).toBe(true) + }), + ), ) it.instance("hydrates multiple parts per message", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - const [id] = yield* fill(sessionID, 1) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: id, - type: "text", - text: "extra", - }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "extra", + }) - const result = MessageV2.page({ sessionID, limit: 10 }) - expect(result.items).toHaveLength(1) - expect(result.items[0].parts).toHaveLength(2) - })), + const result = MessageV2.page({ sessionID, limit: 10 }) + expect(result.items).toHaveLength(1) + expect(result.items[0].parts).toHaveLength(2) + }), + ), ) it.instance("accepts cursors from fractional timestamps", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 4, (i: number) => 1000.5 + i) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 4, (i: number) => 1000.5 + i) - const a = MessageV2.page({ sessionID, limit: 2 }) - const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) + const a = MessageV2.page({ sessionID, limit: 2 }) + const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) - expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) - expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) - })), + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + }), + ), ) it.instance("messages with same timestamp are ordered by id", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 4, () => 1000) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 4, () => 1000) - const a = MessageV2.page({ sessionID, limit: 2 }) - expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) - expect(a.more).toBe(true) + const a = MessageV2.page({ sessionID, limit: 2 }) + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(-2)) + expect(a.more).toBe(true) - const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) - expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) - expect(b.more).toBe(false) - })), + const b = MessageV2.page({ sessionID, limit: 2, before: a.cursor! }) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + expect(b.more).toBe(false) + }), + ), ) it.instance("does not return messages from other sessions", () => @@ -269,128 +287,148 @@ describe("MessageV2.page", () => { ) it.instance("large limit returns all messages without cursor", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 10) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 10) - const result = MessageV2.page({ sessionID, limit: 100 }) - expect(result.items).toHaveLength(10) - expect(result.items.map((item) => item.info.id)).toEqual(ids) - expect(result.more).toBe(false) - expect(result.cursor).toBeUndefined() - })), + const result = MessageV2.page({ sessionID, limit: 100 }) + expect(result.items).toHaveLength(10) + expect(result.items.map((item) => item.info.id)).toEqual(ids) + expect(result.more).toBe(false) + expect(result.cursor).toBeUndefined() + }), + ), ) }) describe("MessageV2.stream", () => { it.instance("yields items newest first", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 5) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 5) - const items = Array.from(MessageV2.stream(sessionID)) - expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse()) - })), + const items = Array.from(MessageV2.stream(sessionID)) + expect(items.map((item) => item.info.id)).toEqual(ids.slice().reverse()) + }), + ), ) it.instance("yields nothing for empty session", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const items = Array.from(MessageV2.stream(sessionID)) - expect(items).toHaveLength(0) - })), + withSession(({ sessionID }) => + Effect.gen(function* () { + const items = Array.from(MessageV2.stream(sessionID)) + expect(items).toHaveLength(0) + }), + ), ) it.instance("yields single message", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 1) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 1) - const items = Array.from(MessageV2.stream(sessionID)) - expect(items).toHaveLength(1) - expect(items[0].info.id).toBe(ids[0]) - })), + const items = Array.from(MessageV2.stream(sessionID)) + expect(items).toHaveLength(1) + expect(items[0].info.id).toBe(ids[0]) + }), + ), ) it.instance("hydrates parts for each yielded message", () => - withSession(({ sessionID }) => Effect.gen(function* () { - yield* fill(sessionID, 3) + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 3) - const items = Array.from(MessageV2.stream(sessionID)) - for (const item of items) { - expect(item.parts).toHaveLength(1) - expect(item.parts[0].type).toBe("text") - } - })), + const items = Array.from(MessageV2.stream(sessionID)) + for (const item of items) { + expect(item.parts).toHaveLength(1) + expect(item.parts[0].type).toBe("text") + } + }), + ), ) it.instance("handles sets exceeding internal page size", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 60) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 60) - const items = Array.from(MessageV2.stream(sessionID)) - expect(items).toHaveLength(60) - expect(items[0].info.id).toBe(ids[ids.length - 1]) - expect(items[59].info.id).toBe(ids[0]) - })), + const items = Array.from(MessageV2.stream(sessionID)) + expect(items).toHaveLength(60) + expect(items[0].info.id).toBe(ids[ids.length - 1]) + expect(items[59].info.id).toBe(ids[0]) + }), + ), ) it.instance("is a sync generator", () => - withSession(({ sessionID }) => Effect.gen(function* () { - yield* fill(sessionID, 1) + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 1) - const gen = MessageV2.stream(sessionID) - const first = gen.next() - // sync generator returns { value, done } directly, not a Promise - expect(first).toHaveProperty("value") - expect(first).toHaveProperty("done") - expect(first.done).toBe(false) - })), + const gen = MessageV2.stream(sessionID) + const first = gen.next() + // sync generator returns { value, done } directly, not a Promise + expect(first).toHaveProperty("value") + expect(first).toHaveProperty("done") + expect(first.done).toBe(false) + }), + ), ) }) describe("MessageV2.parts", () => { it.instance("returns parts for a message", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const [id] = yield* fill(sessionID, 1) + withSession(({ sessionID }) => + Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - const result = MessageV2.parts(id) - expect(result).toHaveLength(1) - expect(result[0].type).toBe("text") - expect((result[0] as MessageV2.TextPart).text).toBe("m0") - })), + const result = MessageV2.parts(id) + expect(result).toHaveLength(1) + expect(result[0].type).toBe("text") + expect((result[0] as MessageV2.TextPart).text).toBe("m0") + }), + ), ) it.instance("returns empty array for message with no parts", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const id = yield* addUser(sessionID) + withSession(({ sessionID }) => + Effect.gen(function* () { + const id = yield* addUser(sessionID) - const result = MessageV2.parts(id) - expect(result).toEqual([]) - })), + const result = MessageV2.parts(id) + expect(result).toEqual([]) + }), + ), ) it.instance("returns multiple parts in order", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - const [id] = yield* fill(sessionID, 1) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: id, - type: "text", - text: "second", - }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: id, - type: "text", - text: "third", - }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "second", + }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "third", + }) - const result = MessageV2.parts(id) - expect(result).toHaveLength(3) - expect((result[0] as MessageV2.TextPart).text).toBe("m0") - expect((result[1] as MessageV2.TextPart).text).toBe("second") - expect((result[2] as MessageV2.TextPart).text).toBe("third") - })), + const result = MessageV2.parts(id) + expect(result).toHaveLength(3) + expect((result[0] as MessageV2.TextPart).text).toBe("m0") + expect((result[1] as MessageV2.TextPart).text).toBe("second") + expect((result[2] as MessageV2.TextPart).text).toBe("third") + }), + ), ) it.instance("returns empty for non-existent message id", () => @@ -402,36 +440,40 @@ describe("MessageV2.parts", () => { ) it.instance("parts contain sessionID and messageID", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const [id] = yield* fill(sessionID, 1) + withSession(({ sessionID }) => + Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - const result = MessageV2.parts(id) - expect(result[0].sessionID).toBe(sessionID) - expect(result[0].messageID).toBe(id) - })), + const result = MessageV2.parts(id) + expect(result[0].sessionID).toBe(sessionID) + expect(result[0].messageID).toBe(id) + }), + ), ) }) describe("MessageV2.get", () => { it.instance("returns message with hydrated parts", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const [id] = yield* fill(sessionID, 1) + withSession(({ sessionID }) => + Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - const result = MessageV2.get({ sessionID, messageID: id }) - expect(result.info.id).toBe(id) - expect(result.info.sessionID).toBe(sessionID) - expect(result.info.role).toBe("user") - expect(result.parts).toHaveLength(1) - expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0") - })), + const result = MessageV2.get({ sessionID, messageID: id }) + expect(result.info.id).toBe(id) + expect(result.info.sessionID).toBe(sessionID) + expect(result.info.role).toBe("user") + expect(result.parts).toHaveLength(1) + expect((result.parts[0] as MessageV2.TextPart).text).toBe("m0") + }), + ), ) it.instance("throws NotFoundError for non-existent message", () => - withSession(({ sessionID }) => Effect.gen(function* () { - expect(() => MessageV2.get({ sessionID, messageID: MessageID.ascending() })).toThrow( - "NotFoundError", - ) - })), + withSession(({ sessionID }) => + Effect.gen(function* () { + expect(() => MessageV2.get({ sessionID, messageID: MessageID.ascending() })).toThrow("NotFoundError") + }), + ), ) it.instance("scopes by session id", () => @@ -451,192 +493,212 @@ describe("MessageV2.get", () => { ) it.instance("returns message with multiple parts", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - const [id] = yield* fill(sessionID, 1) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: id, - type: "text", - text: "extra", - }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text: "extra", + }) - const result = MessageV2.get({ sessionID, messageID: id }) - expect(result.parts).toHaveLength(2) - })), + const result = MessageV2.get({ sessionID, messageID: id }) + expect(result.parts).toHaveLength(2) + }), + ), ) it.instance("returns assistant message with correct role", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - const uid = yield* addUser(sessionID, "hello") - const aid = yield* addAssistant(sessionID, uid) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const uid = yield* addUser(sessionID, "hello") + const aid = yield* addAssistant(sessionID, uid) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: aid, - type: "text", - text: "response", - }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: aid, + type: "text", + text: "response", + }) - const result = MessageV2.get({ sessionID, messageID: aid }) - expect(result.info.role).toBe("assistant") - expect(result.parts).toHaveLength(1) - expect((result.parts[0] as MessageV2.TextPart).text).toBe("response") - })), + const result = MessageV2.get({ sessionID, messageID: aid }) + expect(result.info.role).toBe("assistant") + expect(result.parts).toHaveLength(1) + expect((result.parts[0] as MessageV2.TextPart).text).toBe("response") + }), + ), ) it.instance("returns message with zero parts", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const id = yield* addUser(sessionID) + withSession(({ sessionID }) => + Effect.gen(function* () { + const id = yield* addUser(sessionID) - const result = MessageV2.get({ sessionID, messageID: id }) - expect(result.info.id).toBe(id) - expect(result.parts).toEqual([]) - })), + const result = MessageV2.get({ sessionID, messageID: id }) + expect(result.info.id).toBe(id) + expect(result.parts).toEqual([]) + }), + ), ) }) describe("MessageV2.filterCompacted", () => { it.instance("returns all messages when no compaction", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const ids = yield* fill(sessionID, 5) + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 5) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result).toHaveLength(5) - // reversed from newest-first to chronological - expect(result.map((item) => item.info.id)).toEqual(ids) - })), + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + expect(result).toHaveLength(5) + // reversed from newest-first to chronological + expect(result.map((item) => item.info.id)).toEqual(ids) + }), + ), ) it.instance("stops at compaction boundary and returns chronological order", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - // Chronological: u1(+compaction part), a1(summary, parentID=u1), u2, a2 - // Stream (newest first): a2, u2, a1(adds u1 to completed), u1(in completed + compaction) -> break - const u1 = yield* addUser(sessionID, "first question") - const a1 = yield* addAssistant(sessionID, u1, { summary: true, finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a1, - type: "text", - text: "summary", - }) - yield* addCompactionPart(sessionID, u1) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + // Chronological: u1(+compaction part), a1(summary, parentID=u1), u2, a2 + // Stream (newest first): a2, u2, a1(adds u1 to completed), u1(in completed + compaction) -> break + const u1 = yield* addUser(sessionID, "first question") + const a1 = yield* addAssistant(sessionID, u1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "summary", + }) + yield* addCompactionPart(sessionID, u1) - const u2 = yield* addUser(sessionID, "new question") - const a2 = yield* addAssistant(sessionID, u2) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a2, - type: "text", - text: "new response", - }) + const u2 = yield* addUser(sessionID, "new question") + const a2 = yield* addAssistant(sessionID, u2) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "new response", + }) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - // Includes compaction boundary: u1, a1, u2, a2 - expect(result[0].info.id).toBe(u1) - expect(result.length).toBe(4) - })), + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + // Includes compaction boundary: u1, a1, u2, a2 + expect(result[0].info.id).toBe(u1) + expect(result.length).toBe(4) + }), + ), ) - it.live("handles empty iterable", () => Effect.sync(() => { - const result = MessageV2.filterCompacted([]) - expect(result).toEqual([]) - })) + it.live("handles empty iterable", () => + Effect.sync(() => { + const result = MessageV2.filterCompacted([]) + expect(result).toEqual([]) + }), + ) it.instance("does not break on compaction part without matching summary", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const u1 = yield* addUser(sessionID, "hello") - yield* addCompactionPart(sessionID, u1) - yield* addUser(sessionID, "world") + withSession(({ sessionID }) => + Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "hello") + yield* addCompactionPart(sessionID, u1) + yield* addUser(sessionID, "world") - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result).toHaveLength(2) - })), + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + expect(result).toHaveLength(2) + }), + ), ) it.instance("skips assistant with error even if marked as summary", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const u1 = yield* addUser(sessionID, "hello") - yield* addCompactionPart(sessionID, u1) + withSession(({ sessionID }) => + Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "hello") + yield* addCompactionPart(sessionID, u1) - const error = new MessageV2.APIError({ - message: "boom", - isRetryable: true, - }).toObject() as MessageV2.Assistant["error"] - yield* addAssistant(sessionID, u1, { summary: true, finish: "end_turn", error }) - yield* addUser(sessionID, "retry") + const error = new MessageV2.APIError({ + message: "boom", + isRetryable: true, + }).toObject() as MessageV2.Assistant["error"] + yield* addAssistant(sessionID, u1, { summary: true, finish: "end_turn", error }) + yield* addUser(sessionID, "retry") - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - // Error assistant doesn't add to completed, so compaction boundary never triggers - expect(result).toHaveLength(3) - })), + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + // Error assistant doesn't add to completed, so compaction boundary never triggers + expect(result).toHaveLength(3) + }), + ), ) it.instance("skips assistant without finish even if marked as summary", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const u1 = yield* addUser(sessionID, "hello") - yield* addCompactionPart(sessionID, u1) + withSession(({ sessionID }) => + Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "hello") + yield* addCompactionPart(sessionID, u1) - // summary=true but no finish - yield* addAssistant(sessionID, u1, { summary: true }) - yield* addUser(sessionID, "next") + // summary=true but no finish + yield* addAssistant(sessionID, u1, { summary: true }) + yield* addUser(sessionID, "next") - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result).toHaveLength(3) - })), + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + expect(result).toHaveLength(3) + }), + ), ) it.instance("ignores original tail when compaction stores tail_start_id", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - const u1 = yield* addUser(sessionID, "first") - const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a1, - type: "text", - text: "first reply", - }) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "first") + const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "first reply", + }) - const u2 = yield* addUser(sessionID, "second") - const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a2, - type: "text", - text: "second reply", - }) + const u2 = yield* addUser(sessionID, "second") + const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "second reply", + }) - const c1 = yield* addUser(sessionID) - yield* addCompactionPart(sessionID, c1, u2) - const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: s1, - type: "text", - text: "summary", - }) + const c1 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c1, u2) + const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s1, + type: "text", + text: "summary", + }) - const u3 = yield* addUser(sessionID, "third") - const a3 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a3, - type: "text", - text: "third reply", - }) + const u3 = yield* addUser(sessionID, "third") + const a3 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a3, + type: "text", + text: "third reply", + }) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) - })), + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) + }), + ), ) it.instance("fork keeps legacy tail_start_id without replaying the tail", () => @@ -704,130 +766,134 @@ describe("MessageV2.filterCompacted", () => { ) it.instance("does not replay an assistant tail when compaction starts inside a turn", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - const u1 = yield* addUser(sessionID, "first") - const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a1, - type: "text", - text: "first reply", - }) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "first") + const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "first reply", + }) - const u2 = yield* addUser(sessionID, "second") - const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a2, - type: "text", - text: "second reply", - }) - const a3 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a3, - type: "text", - text: "tail reply", - }) + const u2 = yield* addUser(sessionID, "second") + const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "second reply", + }) + const a3 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a3, + type: "text", + text: "tail reply", + }) - const c1 = yield* addUser(sessionID) - yield* addCompactionPart(sessionID, c1, a3) - const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: s1, - type: "text", - text: "summary", - }) + const c1 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c1, a3) + const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s1, + type: "text", + text: "summary", + }) - const u3 = yield* addUser(sessionID, "third") - const a4 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a4, - type: "text", - text: "third reply", - }) + const u3 = yield* addUser(sessionID, "third") + const a4 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a4, + type: "text", + text: "third reply", + }) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4]) - })), + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4]) + }), + ), ) it.instance("prefers latest compaction boundary when repeated compactions exist", () => - withSession(({ session, sessionID }) => Effect.gen(function* () { - const u1 = yield* addUser(sessionID, "first") - const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a1, - type: "text", - text: "first reply", - }) + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const u1 = yield* addUser(sessionID, "first") + const a1 = yield* addAssistant(sessionID, u1, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a1, + type: "text", + text: "first reply", + }) - const u2 = yield* addUser(sessionID, "second") - const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a2, - type: "text", - text: "second reply", - }) + const u2 = yield* addUser(sessionID, "second") + const a2 = yield* addAssistant(sessionID, u2, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a2, + type: "text", + text: "second reply", + }) - const c1 = yield* addUser(sessionID) - yield* addCompactionPart(sessionID, c1, u2) - const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: s1, - type: "text", - text: "summary one", - }) + const c1 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c1, u2) + const s1 = yield* addAssistant(sessionID, c1, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s1, + type: "text", + text: "summary one", + }) - const u3 = yield* addUser(sessionID, "third") - const a3 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a3, - type: "text", - text: "third reply", - }) + const u3 = yield* addUser(sessionID, "third") + const a3 = yield* addAssistant(sessionID, u3, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a3, + type: "text", + text: "third reply", + }) - const c2 = yield* addUser(sessionID) - yield* addCompactionPart(sessionID, c2, u3) - const s2 = yield* addAssistant(sessionID, c2, { summary: true, finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: s2, - type: "text", - text: "summary two", - }) + const c2 = yield* addUser(sessionID) + yield* addCompactionPart(sessionID, c2, u3) + const s2 = yield* addAssistant(sessionID, c2, { summary: true, finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: s2, + type: "text", + text: "summary two", + }) - const u4 = yield* addUser(sessionID, "fourth") - const a4 = yield* addAssistant(sessionID, u4, { finish: "end_turn" }) - yield* session.updatePart({ - id: PartID.ascending(), - sessionID, - messageID: a4, - type: "text", - text: "fourth reply", - }) + const u4 = yield* addUser(sessionID, "fourth") + const a4 = yield* addAssistant(sessionID, u4, { finish: "end_turn" }) + yield* session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: a4, + type: "text", + text: "fourth reply", + }) - const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const result = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4]) - })), + expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4]) + }), + ), ) test("works with array input", () => { @@ -876,57 +942,65 @@ describe("MessageV2.cursor", () => { describe("MessageV2 consistency", () => { it.instance("page hydration matches get for each message", () => - withSession(({ sessionID }) => Effect.gen(function* () { - yield* fill(sessionID, 3) + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 3) - const paged = MessageV2.page({ sessionID, limit: 10 }) - for (const item of paged.items) { - const got = MessageV2.get({ sessionID, messageID: item.info.id as MessageID }) - expect(got.info).toEqual(item.info) - expect(got.parts).toEqual(item.parts) - } - })), + const paged = MessageV2.page({ sessionID, limit: 10 }) + for (const item of paged.items) { + const got = MessageV2.get({ sessionID, messageID: item.info.id as MessageID }) + expect(got.info).toEqual(item.info) + expect(got.parts).toEqual(item.parts) + } + }), + ), ) it.instance("parts from get match standalone parts call", () => - withSession(({ sessionID }) => Effect.gen(function* () { - const [id] = yield* fill(sessionID, 1) + withSession(({ sessionID }) => + Effect.gen(function* () { + const [id] = yield* fill(sessionID, 1) - const got = MessageV2.get({ sessionID, messageID: id }) - const standalone = MessageV2.parts(id) - expect(got.parts).toEqual(standalone) - })), + const got = MessageV2.get({ sessionID, messageID: id }) + const standalone = MessageV2.parts(id) + expect(got.parts).toEqual(standalone) + }), + ), ) it.instance("stream collects same messages as exhaustive page iteration", () => - withSession(({ sessionID }) => Effect.gen(function* () { - yield* fill(sessionID, 7) + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 7) - const streamed = Array.from(MessageV2.stream(sessionID)) + const streamed = Array.from(MessageV2.stream(sessionID)) - const paged = [] as MessageV2.WithParts[] - let cursor: string | undefined - while (true) { - const result = MessageV2.page({ sessionID, limit: 3, before: cursor }) - for (let i = result.items.length - 1; i >= 0; i--) { - paged.push(result.items[i]) + const paged = [] as MessageV2.WithParts[] + let cursor: string | undefined + while (true) { + const result = MessageV2.page({ sessionID, limit: 3, before: cursor }) + for (let i = result.items.length - 1; i >= 0; i--) { + paged.push(result.items[i]) + } + if (!result.more || !result.cursor) break + cursor = result.cursor } - if (!result.more || !result.cursor) break - cursor = result.cursor - } - expect(streamed.map((m) => m.info.id)).toEqual(paged.map((m) => m.info.id)) - })), + expect(streamed.map((m) => m.info.id)).toEqual(paged.map((m) => m.info.id)) + }), + ), ) it.instance("filterCompacted of full stream returns same as Array.from when no compaction", () => - withSession(({ sessionID }) => Effect.gen(function* () { - yield* fill(sessionID, 4) + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 4) - const filtered = MessageV2.filterCompacted(MessageV2.stream(sessionID)) - const all = Array.from(MessageV2.stream(sessionID)).reverse() + const filtered = MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const all = Array.from(MessageV2.stream(sessionID)).reverse() - expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id)) - })), + expect(filtered.map((m) => m.info.id)).toEqual(all.map((m) => m.info.id)) + }), + ), ) }) From 5773d43cbf356b2ede8e9c000ae5f8bfbf017e75 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 11 May 2026 19:50:35 -0500 Subject: [PATCH 11/32] ci: GitHub Actions dependencies (#26962) --- .github/actions/setup-bun/action.yml | 4 +- .../actions/setup-git-committer/action.yml | 2 +- .github/workflows/beta.yml | 2 +- .github/workflows/close-issues.yml | 4 +- .github/workflows/close-stale-prs.yml | 2 +- .github/workflows/compliance-close.yml | 2 +- .github/workflows/containers.yml | 8 +-- .github/workflows/deploy.yml | 4 +- .github/workflows/docs-locale-sync.yml | 2 +- .github/workflows/docs-update.yml | 4 +- .github/workflows/duplicate-issues.yml | 4 +- .github/workflows/generate.yml | 2 +- .github/workflows/nix-eval.yml | 4 +- .github/workflows/nix-hashes.yml | 10 ++-- .github/workflows/notify-discord.yml | 2 +- .github/workflows/opencode.yml | 4 +- .github/workflows/pr-management.yml | 4 +- .github/workflows/pr-standards.yml | 4 +- .github/workflows/publish-github-action.yml | 2 +- .github/workflows/publish-vscode.yml | 2 +- .github/workflows/publish.yml | 52 +++++++++---------- .github/workflows/release-github-action.yml | 2 +- .github/workflows/review.yml | 2 +- .github/workflows/stats.yml | 2 +- .github/workflows/storybook.yml | 2 +- .github/workflows/sync-zed-extension.yml | 2 +- .github/workflows/test.yml | 18 +++---- .github/workflows/triage.yml | 2 +- .github/workflows/typecheck.yml | 2 +- 29 files changed, 78 insertions(+), 78 deletions(-) diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 9859174a2e..35f42462b8 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -23,7 +23,7 @@ runs: fi - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }} bun-download-url: ${{ steps.bun-url.outputs.url }} @@ -34,7 +34,7 @@ runs: run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT" - name: Cache Bun dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ steps.cache.outputs.dir }} key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} diff --git a/.github/actions/setup-git-committer/action.yml b/.github/actions/setup-git-committer/action.yml index 87d2f5d0d4..65c974c6ab 100644 --- a/.github/actions/setup-git-committer/action.yml +++ b/.github/actions/setup-git-committer/action.yml @@ -19,7 +19,7 @@ runs: steps: - name: Create app token id: apptoken - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 with: app-id: ${{ inputs.opencode-app-id }} private-key: ${{ inputs.opencode-app-secret }} diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index a7106667b1..e93d5fbdb2 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -13,7 +13,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 diff --git a/.github/workflows/close-issues.yml b/.github/workflows/close-issues.yml index 04b6ae7ac8..b8a2e3f575 100644 --- a/.github/workflows/close-issues.yml +++ b/.github/workflows/close-issues.yml @@ -12,9 +12,9 @@ jobs: contents: read issues: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: latest diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index e0e571b469..3a0fa4b5c7 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 15 steps: - name: Close inactive PRs - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml index c3bcf9f686..14e68701e5 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/compliance-close.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Close non-compliant issues and PRs after 2 hours - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const { data: items } = await github.rest.issues.listForRepo({ diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index c7df066d41..15bf078316 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -21,18 +21,18 @@ jobs: REGISTRY: ghcr.io/${{ github.repository_owner }} TAG: "24.04" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: ./.github/actions/setup-bun - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index abd8bafdd6..7b4f53a98e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,11 +13,11 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: ./.github/actions/setup-bun - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" diff --git a/.github/workflows/docs-locale-sync.yml b/.github/workflows/docs-locale-sync.yml index 9689eee6d2..5f921e8bb7 100644 --- a/.github/workflows/docs-locale-sync.yml +++ b/.github/workflows/docs-locale-sync.yml @@ -16,7 +16,7 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/docs-update.yml b/.github/workflows/docs-update.yml index 900ad2b0c5..4767dec539 100644 --- a/.github/workflows/docs-update.yml +++ b/.github/workflows/docs-update.yml @@ -18,7 +18,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 # Fetch full history to access commits @@ -43,7 +43,7 @@ jobs: - name: Run opencode if: steps.commits.outputs.has_commits == 'true' - uses: sst/opencode/github@latest + uses: sst/opencode/github@2c14fc5586fe0b88e5c04732d2e846769cc35671 # latest env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} with: diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index 6c1943fe7b..4648a2d0c3 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -13,7 +13,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 @@ -125,7 +125,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 706ab2989e..324cfec020 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -13,7 +13,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/nix-eval.yml b/.github/workflows/nix-eval.yml index c76b2c9729..75332695a1 100644 --- a/.github/workflows/nix-eval.yml +++ b/.github/workflows/nix-eval.yml @@ -20,10 +20,10 @@ jobs: timeout-minutes: 15 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Nix - uses: nixbuild/nix-quick-install-action@v34 + uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - name: Evaluate flake outputs (all systems) run: | diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index 6b5b3929ad..085f8895c2 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -41,10 +41,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Nix - uses: nixbuild/nix-quick-install-action@v34 + uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - name: Compute node_modules hash id: hash @@ -72,7 +72,7 @@ jobs: echo "Computed hash for ${SYSTEM}: $HASH" - name: Upload hash - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: hash-${{ matrix.system }} path: hash.txt @@ -85,7 +85,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false fetch-depth: 0 @@ -102,7 +102,7 @@ jobs: git pull --rebase --autostash origin "$GITHUB_REF_NAME" - name: Download hash artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: hashes pattern: hash-* diff --git a/.github/workflows/notify-discord.yml b/.github/workflows/notify-discord.yml index b1d8053603..0b2b1cde05 100644 --- a/.github/workflows/notify-discord.yml +++ b/.github/workflows/notify-discord.yml @@ -9,6 +9,6 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Send nicely-formatted embed to Discord - uses: SethCohen/github-releases-to-discord@v1 + uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0 with: webhook_url: ${{ secrets.DISCORD_WEBHOOK }} diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 76e75fcaef..3469c21917 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -21,12 +21,12 @@ jobs: issues: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: ./.github/actions/setup-bun - name: Run opencode - uses: anomalyco/opencode/github@latest + uses: anomalyco/opencode/github@2c14fc5586fe0b88e5c04732d2e846769cc35671 # latest env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_PERMISSION: '{"bash": "deny"}' diff --git a/.github/workflows/pr-management.yml b/.github/workflows/pr-management.yml index 35bd7ae36f..b6aa4e589d 100644 --- a/.github/workflows/pr-management.yml +++ b/.github/workflows/pr-management.yml @@ -12,7 +12,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 @@ -78,7 +78,7 @@ jobs: issues: write steps: - name: Add Contributor Label - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const isPR = !!context.payload.pull_request; diff --git a/.github/workflows/pr-standards.yml b/.github/workflows/pr-standards.yml index 1edbd5d061..06838089d3 100644 --- a/.github/workflows/pr-standards.yml +++ b/.github/workflows/pr-standards.yml @@ -12,7 +12,7 @@ jobs: pull-requests: write steps: - name: Check PR standards - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const pr = context.payload.pull_request; @@ -159,7 +159,7 @@ jobs: pull-requests: write steps: - name: Check PR template compliance - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const pr = context.payload.pull_request; diff --git a/.github/workflows/publish-github-action.yml b/.github/workflows/publish-github-action.yml index d2789373a3..e5ca91b561 100644 --- a/.github/workflows/publish-github-action.yml +++ b/.github/workflows/publish-github-action.yml @@ -16,7 +16,7 @@ jobs: publish: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: fetch-depth: 0 diff --git a/.github/workflows/publish-vscode.yml b/.github/workflows/publish-vscode.yml index f49a105780..00c7e26048 100644 --- a/.github/workflows/publish-vscode.yml +++ b/.github/workflows/publish-vscode.yml @@ -15,7 +15,7 @@ jobs: publish: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: fetch-depth: 0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5f7ee96b90..bef1e70293 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -35,7 +35,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.repository == 'anomalyco/opencode' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: fetch-depth: 0 @@ -72,7 +72,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.repository == 'anomalyco/opencode' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: fetch-tags: true @@ -95,14 +95,14 @@ jobs: GH_REPO: ${{ needs.version.outputs.repo }} GH_TOKEN: ${{ steps.committer.outputs.token }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: opencode-cli path: | packages/opencode/dist/opencode-darwin* packages/opencode/dist/opencode-linux* - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: opencode-cli-windows path: packages/opencode/dist/opencode-windows* @@ -123,9 +123,9 @@ jobs: AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: opencode-cli-windows path: packages/opencode/dist @@ -138,13 +138,13 @@ jobs: opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - name: Azure login - uses: azure/login@v2 + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 with: client-id: ${{ env.AZURE_CLIENT_ID }} tenant-id: ${{ env.AZURE_TENANT_ID }} subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} - - uses: azure/artifact-signing-action@v1 + - uses: azure/artifact-signing-action@b443cf8ea4124818d2ea9f043cba29fc3ec47b16 # v1.2.0 with: endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }} signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} @@ -201,7 +201,7 @@ jobs: --clobber ` --repo "${{ needs.version.outputs.repo }}" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: opencode-cli-signed-windows path: | @@ -249,9 +249,9 @@ jobs: platform_flag: --linux runs-on: ${{ matrix.settings.host }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - uses: apple-actions/import-codesign-certs@v2 + - uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0 if: runner.os == 'macOS' with: keychain: build @@ -268,19 +268,19 @@ jobs: - name: Azure login if: runner.os == 'Windows' - uses: azure/login@v2 + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 with: client-id: ${{ env.AZURE_CLIENT_ID }} tenant-id: ${{ env.AZURE_TENANT_ID }} subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" - name: Cache apt packages if: contains(matrix.settings.host, 'ubuntu') - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/apt-cache key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }} @@ -388,12 +388,12 @@ jobs: } } - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: opencode-desktop-${{ matrix.settings.target }} path: packages/desktop/dist/* - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: needs.version.outputs.release with: name: latest-yml-${{ matrix.settings.target }} @@ -408,44 +408,44 @@ jobs: if: always() && !failure() && !cancelled() runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: ./.github/actions/setup-bun - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" registry-url: "https://registry.npmjs.org" - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: opencode-cli path: packages/opencode/dist - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: opencode-cli-windows path: packages/opencode/dist - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: opencode-cli-signed-windows path: packages/opencode/dist - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 if: needs.version.outputs.release with: pattern: latest-yml-* @@ -459,7 +459,7 @@ jobs: opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - name: Cache apt packages (AUR) - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: /var/cache/apt/archives key: ${{ runner.os }}-apt-aur-${{ hashFiles('.github/workflows/publish.yml') }} diff --git a/.github/workflows/release-github-action.yml b/.github/workflows/release-github-action.yml index 3f5caa55c8..4a1d7218bb 100644 --- a/.github/workflows/release-github-action.yml +++ b/.github/workflows/release-github-action.yml @@ -16,7 +16,7 @@ jobs: release: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 2bd1f0c4a0..00a4fba8ca 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -25,7 +25,7 @@ jobs: fi - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 diff --git a/.github/workflows/stats.yml b/.github/workflows/stats.yml index 824733901d..bc97cfcd71 100644 --- a/.github/workflows/stats.yml +++ b/.github/workflows/stats.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 6d143a8a22..1e652104d6 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -29,7 +29,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/.github/workflows/sync-zed-extension.yml b/.github/workflows/sync-zed-extension.yml index f14487cde9..6e4b44083c 100644 --- a/.github/workflows/sync-zed-extension.yml +++ b/.github/workflows/sync-zed-extension.yml @@ -10,7 +10,7 @@ jobs: name: Release Zed Extension runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f226d3483a..4a65b99277 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,12 +37,12 @@ jobs: shell: bash steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" @@ -55,7 +55,7 @@ jobs: git config --global user.name "opencode" - name: Cache Turbo - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: node_modules/.cache/turbo key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }} @@ -75,7 +75,7 @@ jobs: - name: Publish unit reports if: always() - uses: mikepenz/action-junit-report@v6 + uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0 with: report_paths: packages/*/.artifacts/unit/junit.xml check_name: "unit results (${{ matrix.settings.name }})" @@ -85,7 +85,7 @@ jobs: - name: Upload unit artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }} include-hidden-files: true @@ -111,12 +111,12 @@ jobs: shell: bash steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "24" @@ -131,7 +131,7 @@ jobs: - name: Cache Playwright browsers id: playwright-cache - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ github.workspace }}/.playwright-browsers key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium @@ -155,7 +155,7 @@ jobs: - name: Upload Playwright artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }} if-no-files-found: ignore diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 99e7b5b34f..27852a12ce 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -12,7 +12,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 1 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index b247d24b40..fc9a52797c 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -12,7 +12,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun uses: ./.github/actions/setup-bun From 0d9c5341846819db96b2a7e08f0357d6c24507fa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 20:59:31 -0400 Subject: [PATCH 12/32] test(snapshot): migrate snapshot tests to Effect runner (#26964) --- .../opencode/test/snapshot/snapshot.test.ts | 2368 +++++++---------- 1 file changed, 977 insertions(+), 1391 deletions(-) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 99ddfe72d4..fa167281b9 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1,13 +1,15 @@ -import { afterEach, test, expect } from "bun:test" +import { afterEach, expect } from "bun:test" import { $ } from "bun" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import fs from "fs/promises" import path from "path" -import { Effect } from "effect" +import { Effect, Fiber } from "effect" import { Snapshot } from "../../src/snapshot" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" -import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Snapshot.defaultLayer) // Git always outputs /-separated paths internally. Snapshot.patch() joins them // with path.join (which produces \ on Windows) then normalizes back to /. @@ -18,1515 +20,1099 @@ afterEach(async () => { await disposeAllInstances() }) -async function bootstrap() { - return tmpdir({ - git: true, - init: async (dir) => { - const unique = Math.random().toString(36).slice(2) - const aContent = `A${unique}` - const bContent = `B${unique}` - await Filesystem.write(`${dir}/a.txt`, aContent) - await Filesystem.write(`${dir}/b.txt`, bContent) - await $`git add .`.cwd(dir).quiet() - await $`git commit -m init`.cwd(dir).quiet() - return { - aContent, - bContent, - } - }, +const exec = (cwd: string, command: string[]) => + Effect.promise(async () => { + const proc = Bun.spawn(command, { cwd, stdout: "ignore", stderr: "pipe" }) + const code = await proc.exited + if (code !== 0) throw new Error(`${command.join(" ")} failed: ${await new Response(proc.stderr).text()}`) }) -} -function run(dir: string, body: (snapshot: Snapshot.Interface) => Effect.Effect) { - return Effect.runPromise( - Effect.gen(function* () { - const snapshot = yield* Snapshot.Service - return yield* body(snapshot) - }).pipe(provideInstance(dir), Effect.provide(Snapshot.defaultLayer)), +const write = (file: string, content: string | Uint8Array) => Effect.promise(() => Filesystem.write(file, content)) +const readText = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8")) +const exists = (file: string) => + Effect.promise(() => + fs + .access(file) + .then(() => true) + .catch(() => false), ) -} +const mkdirp = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true })) +const rm = (file: string) => Effect.promise(() => fs.rm(file, { recursive: true, force: true })) -test("tracks deleted files correctly", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await $`rm ${tmp.path}/a.txt`.quiet() - - expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files).toContain(fwd(tmp.path, "a.txt")) - }, - }) +const initialize = Effect.fn("SnapshotTest.initialize")(function* (dir: string) { + const unique = Math.random().toString(36).slice(2) + const aContent = `A${unique}` + const bContent = `B${unique}` + yield* write(`${dir}/a.txt`, aContent) + yield* write(`${dir}/b.txt`, bContent) + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + return { aContent, bContent } }) -test("revert should remove new files", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +type Bootstrapped = { path: string; extra: { aContent: string; bContent: string } } - await Filesystem.write(`${tmp.path}/new.txt`, "NEW") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - expect( - await fs - .access(`${tmp.path}/new.txt`) - .then(() => true) - .catch(() => false), - ).toBe(false) - }, - }) +const bootstrap = Effect.fn("SnapshotTest.bootstrap")(function* () { + const tmp = yield* TestInstance + return { path: tmp.directory, extra: yield* initialize(tmp.directory) } }) -test("revert in subdirectory", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await $`mkdir -p ${tmp.path}/sub`.quiet() - await Filesystem.write(`${tmp.path}/sub/file.txt`, "SUB") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - expect( - await fs - .access(`${tmp.path}/sub/file.txt`) - .then(() => true) - .catch(() => false), - ).toBe(false) - // Note: revert currently only removes files, not directories - // The empty subdirectory will remain - }, +const withTrackedSnapshot = ( + fn: (input: { tmp: Bootstrapped; snapshot: Snapshot.Interface; before: string }) => Effect.Effect, +) => + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + return yield* fn({ tmp, snapshot, before: before! }) }) + +const bootstrapScoped = Effect.fn("SnapshotTest.bootstrapScoped")(function* () { + const dir = yield* tmpdirScoped({ git: true }).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)) + return { path: dir, extra: yield* initialize(dir) } }) -test("multiple file operations", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +const scopedGitTmpdir = () => tmpdirScoped({ git: true }).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)) - await $`rm ${tmp.path}/a.txt`.quiet() - await Filesystem.write(`${tmp.path}/c.txt`, "C") - await $`mkdir -p ${tmp.path}/dir`.quiet() - await Filesystem.write(`${tmp.path}/dir/d.txt`, "D") - await Filesystem.write(`${tmp.path}/b.txt`, "MODIFIED") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe(tmp.extra.aContent) - expect( - await fs - .access(`${tmp.path}/c.txt`) - .then(() => true) - .catch(() => false), - ).toBe(false) - // Note: revert currently only removes files, not directories - // The empty directory will remain - expect(await fs.readFile(`${tmp.path}/b.txt`, "utf-8")).toBe(tmp.extra.bContent) - }, +const cleanupWorktree = (repo: string, worktree: string, files: string[] = []) => + Effect.promise(async () => { + await $`git worktree remove --force ${worktree}`.cwd(repo).quiet().nothrow() + await fs.rm(worktree, { recursive: true, force: true }).catch(() => undefined) + await Promise.all(files.map((file) => fs.rm(file, { recursive: true, force: true }).catch(() => undefined))) }) -}) -test("empty directory handling", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +const withGitConfigGlobal = (config: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.GIT_CONFIG_GLOBAL + process.env.GIT_CONFIG_GLOBAL = config + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.GIT_CONFIG_GLOBAL = previous + else delete process.env.GIT_CONFIG_GLOBAL + }), + ) - await $`mkdir ${tmp.path}/empty`.quiet() +it.instance( + "tracks deleted files correctly", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* rm(`${tmp.path}/a.txt`) + expect((yield* snapshot.patch(before)).files).toContain(fwd(tmp.path, "a.txt")) + }), + ), + { git: true }, +) - expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files.length).toBe(0) - }, - }) -}) +it.instance( + "revert should remove new files", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/new.txt`, "NEW") + const patch = yield* snapshot.patch(before) + yield* snapshot.revert([patch]) + expect(yield* exists(`${tmp.path}/new.txt`)).toBe(false) + }), + ), + { git: true }, +) -test("binary file handling", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +it.instance( + "revert in subdirectory", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* mkdirp(`${tmp.path}/sub`) + yield* write(`${tmp.path}/sub/file.txt`, "SUB") + const patch = yield* snapshot.patch(before) + yield* snapshot.revert([patch]) + expect(yield* exists(`${tmp.path}/sub/file.txt`)).toBe(false) + }), + ), + { git: true }, +) - await Filesystem.write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47])) +it.instance( + "multiple file operations", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* rm(`${tmp.path}/a.txt`) + yield* write(`${tmp.path}/c.txt`, "C") + yield* mkdirp(`${tmp.path}/dir`) + yield* write(`${tmp.path}/dir/d.txt`, "D") + yield* write(`${tmp.path}/b.txt`, "MODIFIED") + const patch = yield* snapshot.patch(before) + yield* snapshot.revert([patch]) + expect(yield* readText(`${tmp.path}/a.txt`)).toBe(tmp.extra.aContent) + expect(yield* exists(`${tmp.path}/c.txt`)).toBe(false) + expect(yield* readText(`${tmp.path}/b.txt`)).toBe(tmp.extra.bContent) + }), + ), + { git: true }, +) - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) +it.instance( + "empty directory handling", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* mkdirp(`${tmp.path}/empty`) + expect((yield* snapshot.patch(before)).files.length).toBe(0) + }), + ), + { git: true }, +) + +it.instance( + "binary file handling", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/image.png`, new Uint8Array([0x89, 0x50, 0x4e, 0x47])) + const patch = yield* snapshot.patch(before) expect(patch.files).toContain(fwd(tmp.path, "image.png")) + yield* snapshot.revert([patch]) + expect(yield* exists(`${tmp.path}/image.png`)).toBe(false) + }), + ), + { git: true }, +) - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - expect( - await fs - .access(`${tmp.path}/image.png`) - .then(() => true) - .catch(() => false), - ).toBe(false) - }, - }) -}) +it.instance( + "symlink handling", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.symlink(`${tmp.path}/a.txt`, `${tmp.path}/link.txt`, "file")) + expect((yield* snapshot.patch(before)).files).toContain(fwd(tmp.path, "link.txt")) + }), + ), + { git: true }, +) -test("symlink handling", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +it.instance( + "file under size limit handling", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024)) + expect((yield* snapshot.patch(before)).files).toContain(fwd(tmp.path, "large.txt")) + }), + ), + { git: true }, +) - await fs.symlink(`${tmp.path}/a.txt`, `${tmp.path}/link.txt`, "file") +it.instance( + "large added files are skipped", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/huge.txt`, new Uint8Array(2 * 1024 * 1024 + 1)) + expect((yield* snapshot.patch(before)).files).toEqual([]) + expect(yield* snapshot.diff(before)).toBe("") + expect(yield* snapshot.track()).toBe(before) + }), + ), + { git: true }, +) - expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files).toContain(fwd(tmp.path, "link.txt")) - }, - }) -}) +it.instance( + "nested directory revert", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* mkdirp(`${tmp.path}/level1/level2/level3`) + yield* write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP") + const patch = yield* snapshot.patch(before) + yield* snapshot.revert([patch]) + expect(yield* exists(`${tmp.path}/level1/level2/level3/deep.txt`)).toBe(false) + }), + ), + { git: true }, +) -test("file under size limit handling", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/large.txt`, "x".repeat(1024 * 1024)) - - expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files).toContain(fwd(tmp.path, "large.txt")) - }, - }) -}) - -test("large added files are skipped", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/huge.txt`, new Uint8Array(2 * 1024 * 1024 + 1)) - - expect((await run(tmp.path, (snapshot) => snapshot.patch(before!))).files).toEqual([]) - expect(await run(tmp.path, (snapshot) => snapshot.diff(before!))).toBe("") - expect(await run(tmp.path, (snapshot) => snapshot.track())).toBe(before) - }, - }) -}) - -test("nested directory revert", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await $`mkdir -p ${tmp.path}/level1/level2/level3`.quiet() - await Filesystem.write(`${tmp.path}/level1/level2/level3/deep.txt`, "DEEP") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - expect( - await fs - .access(`${tmp.path}/level1/level2/level3/deep.txt`) - .then(() => true) - .catch(() => false), - ).toBe(false) - }, - }) -}) - -test("special characters in filenames", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/file with spaces.txt`, "SPACES") - await Filesystem.write(`${tmp.path}/file-with-dashes.txt`, "DASHES") - await Filesystem.write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES") - - const files = (await run(tmp.path, (snapshot) => snapshot.patch(before!))).files +it.instance( + "special characters in filenames", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/file with spaces.txt`, "SPACES") + yield* write(`${tmp.path}/file-with-dashes.txt`, "DASHES") + yield* write(`${tmp.path}/file_with_underscores.txt`, "UNDERSCORES") + const files = (yield* snapshot.patch(before)).files expect(files).toContain(fwd(tmp.path, "file with spaces.txt")) expect(files).toContain(fwd(tmp.path, "file-with-dashes.txt")) expect(files).toContain(fwd(tmp.path, "file_with_underscores.txt")) - }, - }) -}) + }), + ), + { git: true }, +) -test("revert with empty patches", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // Should not crash with empty patches - expect(run(tmp.path, (snapshot) => snapshot.revert([]))).resolves.toBeUndefined() +it.instance( + "revert with empty patches", + Effect.gen(function* () { + yield* bootstrap() + const snapshot = yield* Snapshot.Service + yield* snapshot.revert([]) + yield* snapshot.revert([{ hash: "dummy", files: [] }]) + }), + { git: true }, +) - // Should not crash with patches that have empty file lists - expect(run(tmp.path, (snapshot) => snapshot.revert([{ hash: "dummy", files: [] }]))).resolves.toBeUndefined() - }, - }) -}) - -test("patch with invalid hash", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - // Create a change - await Filesystem.write(`${tmp.path}/test.txt`, "TEST") - - // Try to patch with invalid hash - should handle gracefully - const patch = await run(tmp.path, (snapshot) => snapshot.patch("invalid-hash-12345")) +it.instance( + "patch with invalid hash", + withTrackedSnapshot(({ tmp, snapshot }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/test.txt`, "TEST") + const patch = yield* snapshot.patch("invalid-hash-12345") expect(patch.files).toEqual([]) expect(patch.hash).toBe("invalid-hash-12345") - }, - }) -}) + }), + ), + { git: true }, +) -test("revert non-existent file", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - // Try to revert a file that doesn't exist in the snapshot - // This should not crash - expect( - run(tmp.path, (snapshot) => - snapshot.revert([ - { - hash: before!, - files: [`${tmp.path}/nonexistent.txt`], - }, - ]), - ), - ).resolves.toBeUndefined() - }, - }) -}) - -test("unicode filenames", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +it.instance( + "revert non-existent file", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* snapshot.revert([{ hash: before, files: [`${tmp.path}/nonexistent.txt`] }]) + }), + ), + { git: true }, +) +it.instance( + "unicode filenames", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { const unicodeFiles = [ { path: fwd(tmp.path, "文件.txt"), content: "chinese content" }, { path: fwd(tmp.path, "🚀rocket.txt"), content: "emoji content" }, { path: fwd(tmp.path, "café.txt"), content: "accented content" }, { path: fwd(tmp.path, "файл.txt"), content: "cyrillic content" }, ] - - for (const file of unicodeFiles) { - await Filesystem.write(file.path, file.content) - } - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) + yield* Effect.all( + unicodeFiles.map((file) => write(file.path, file.content)), + { concurrency: "unbounded" }, + ) + const patch = yield* snapshot.patch(before) expect(patch.files.length).toBe(4) + for (const file of unicodeFiles) expect(patch.files).toContain(file.path) + yield* snapshot.revert([patch]) + for (const file of unicodeFiles) expect(yield* exists(file.path)).toBe(false) + }), + ), + { git: true }, +) - for (const file of unicodeFiles) { - expect(patch.files).toContain(file.path) - } +it.instance.skip( + "unicode filenames modification and restore", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + const chineseFile = fwd(tmp.path, "文件.txt") + const cyrillicFile = fwd(tmp.path, "файл.txt") + yield* write(chineseFile, "original chinese") + yield* write(cyrillicFile, "original cyrillic") + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(chineseFile, "modified chinese") + yield* write(cyrillicFile, "modified cyrillic") + const patch = yield* snapshot.patch(before!) + expect(patch.files).toContain(chineseFile) + expect(patch.files).toContain(cyrillicFile) + yield* snapshot.revert([patch]) + expect(yield* readText(chineseFile)).toBe("original chinese") + expect(yield* readText(cyrillicFile)).toBe("original cyrillic") + }), + { git: true }, +) - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - for (const file of unicodeFiles) { - expect( - await fs - .access(file.path) - .then(() => true) - .catch(() => false), - ).toBe(false) - } - }, - }) -}) - -test.skip("unicode filenames modification and restore", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const chineseFile = fwd(tmp.path, "文件.txt") - const cyrillicFile = fwd(tmp.path, "файл.txt") - - await Filesystem.write(chineseFile, "original chinese") - await Filesystem.write(cyrillicFile, "original cyrillic") - - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(chineseFile, "modified chinese") - await Filesystem.write(cyrillicFile, "modified cyrillic") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - expect(patch.files).toContain(chineseFile) - expect(patch.files).toContain(cyrillicFile) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - expect(await fs.readFile(chineseFile, "utf-8")).toBe("original chinese") - expect(await fs.readFile(cyrillicFile, "utf-8")).toBe("original cyrillic") - }, - }) -}) - -test("unicode filenames in subdirectories", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await $`mkdir -p "${tmp.path}/目录/подкаталог"`.quiet() +it.instance( + "unicode filenames in subdirectories", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* mkdirp(`${tmp.path}/目录/подкаталог`) const deepFile = fwd(tmp.path, "目录", "подкаталог", "文件.txt") - await Filesystem.write(deepFile, "deep unicode content") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) + yield* write(deepFile, "deep unicode content") + const patch = yield* snapshot.patch(before) expect(patch.files).toContain(deepFile) + yield* snapshot.revert([patch]) + expect(yield* exists(deepFile)).toBe(false) + }), + ), + { git: true }, +) - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - expect( - await fs - .access(deepFile) - .then(() => true) - .catch(() => false), - ).toBe(false) - }, - }) -}) - -test("very long filenames", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - const longName = "a".repeat(200) + ".txt" - const longFile = fwd(tmp.path, longName) - - await Filesystem.write(longFile, "long filename content") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) +it.instance( + "very long filenames", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + const longFile = fwd(tmp.path, `${"a".repeat(200)}.txt`) + yield* write(longFile, "long filename content") + const patch = yield* snapshot.patch(before) expect(patch.files).toContain(longFile) + yield* snapshot.revert([patch]) + expect(yield* exists(longFile)).toBe(false) + }), + ), + { git: true }, +) - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - expect( - await fs - .access(longFile) - .then(() => true) - .catch(() => false), - ).toBe(false) - }, - }) -}) - -test("hidden files", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/.hidden`, "hidden content") - await Filesystem.write(`${tmp.path}/.gitignore`, "*.log") - await Filesystem.write(`${tmp.path}/.config`, "config content") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) +it.instance( + "hidden files", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/.hidden`, "hidden content") + yield* write(`${tmp.path}/.gitignore`, "*.log") + yield* write(`${tmp.path}/.config`, "config content") + const patch = yield* snapshot.patch(before) expect(patch.files).toContain(fwd(tmp.path, ".hidden")) expect(patch.files).toContain(fwd(tmp.path, ".gitignore")) expect(patch.files).toContain(fwd(tmp.path, ".config")) - }, - }) -}) + }), + ), + { git: true }, +) -test("nested symlinks", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await $`mkdir -p ${tmp.path}/sub/dir`.quiet() - await Filesystem.write(`${tmp.path}/sub/dir/target.txt`, "target content") - await fs.symlink(`${tmp.path}/sub/dir/target.txt`, `${tmp.path}/sub/dir/link.txt`, "file") - await fs.symlink(`${tmp.path}/sub`, `${tmp.path}/sub-link`, "dir") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) +it.instance( + "nested symlinks", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* mkdirp(`${tmp.path}/sub/dir`) + yield* write(`${tmp.path}/sub/dir/target.txt`, "target content") + yield* Effect.promise(() => fs.symlink(`${tmp.path}/sub/dir/target.txt`, `${tmp.path}/sub/dir/link.txt`, "file")) + yield* Effect.promise(() => fs.symlink(`${tmp.path}/sub`, `${tmp.path}/sub-link`, "dir")) + const patch = yield* snapshot.patch(before) expect(patch.files).toContain(fwd(tmp.path, "sub", "dir", "link.txt")) expect(patch.files).toContain(fwd(tmp.path, "sub-link")) - }, - }) -}) + }), + ), + { git: true }, +) -test("file permissions and ownership changes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "file permissions and ownership changes", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.chmod(`${tmp.path}/a.txt`, 0o600)) + yield* Effect.promise(() => fs.chmod(`${tmp.path}/a.txt`, 0o755)) + yield* Effect.promise(() => fs.chmod(`${tmp.path}/a.txt`, 0o644)) + expect((yield* snapshot.patch(before)).files.length).toBe(0) + }), + ), + { git: true }, +) + +it.instance( + "circular symlinks", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* Effect.promise(() => + fs.symlink(`${tmp.path}/circular`, `${tmp.path}/circular`, "dir").catch(() => undefined), + ) + expect((yield* snapshot.patch(before)).files.length).toBeGreaterThanOrEqual(0) + }), + ), + { git: true }, +) + +it.live( + "source project gitignore is respected - ignored files are not snapshotted", + Effect.gen(function* () { + const dir = yield* scopedGitTmpdir() + yield* write(`${dir}/.gitignore`, "*.ignored\nbuild/\nnode_modules/\n") + yield* write(`${dir}/tracked.txt`, "tracked content") + yield* write(`${dir}/ignored.ignored`, "ignored content") + yield* mkdirp(`${dir}/build`) + yield* write(`${dir}/build/output.js`, "build output") + yield* write(`${dir}/normal.js`, "normal js") + yield* exec(dir, ["git", "add", "."]) + yield* exec(dir, ["git", "commit", "-m", "init"]) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() expect(before).toBeTruthy() + yield* write(`${dir}/tracked.txt`, "modified tracked") + yield* write(`${dir}/new.ignored`, "new ignored") + yield* write(`${dir}/new-tracked.txt`, "new tracked") + yield* write(`${dir}/build/new-build.js`, "new build file") + const patch = yield* snapshot.patch(before!) + expect(patch.files).toContain(fwd(dir, "new-tracked.txt")) + expect(patch.files).toContain(fwd(dir, "tracked.txt")) + expect(patch.files).not.toContain(fwd(dir, "new.ignored")) + expect(patch.files).not.toContain(fwd(dir, "ignored.ignored")) + expect(patch.files).not.toContain(fwd(dir, "build/output.js")) + expect(patch.files).not.toContain(fwd(dir, "build/new-build.js")) + }).pipe(provideInstance(dir)) + }), +) - // Change permissions multiple times - await $`chmod 600 ${tmp.path}/a.txt`.quiet() - await $`chmod 755 ${tmp.path}/a.txt`.quiet() - await $`chmod 644 ${tmp.path}/a.txt`.quiet() - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - // Note: git doesn't track permission changes on existing files by default - // Only tracks executable bit when files are first added - expect(patch.files.length).toBe(0) - }, - }) -}) - -test("circular symlinks", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - // Create circular symlink - await fs.symlink(`${tmp.path}/circular`, `${tmp.path}/circular`, "dir").catch(() => {}) - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash - }, - }) -}) - -test("source project gitignore is respected - ignored files are not snapshotted", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - // Create gitignore BEFORE any tracking - await Filesystem.write(`${dir}/.gitignore`, "*.ignored\nbuild/\nnode_modules/\n") - await Filesystem.write(`${dir}/tracked.txt`, "tracked content") - await Filesystem.write(`${dir}/ignored.ignored`, "ignored content") - await $`mkdir -p ${dir}/build`.quiet() - await Filesystem.write(`${dir}/build/output.js`, "build output") - await Filesystem.write(`${dir}/normal.js`, "normal js") - await $`git add .`.cwd(dir).quiet() - await $`git commit -m init`.cwd(dir).quiet() - }, - }) - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - // Modify tracked files and create new ones - some ignored, some not - await Filesystem.write(`${tmp.path}/tracked.txt`, "modified tracked") - await Filesystem.write(`${tmp.path}/new.ignored`, "new ignored") - await Filesystem.write(`${tmp.path}/new-tracked.txt`, "new tracked") - await Filesystem.write(`${tmp.path}/build/new-build.js`, "new build file") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - - // Modified and new tracked files should be in snapshot - expect(patch.files).toContain(fwd(tmp.path, "new-tracked.txt")) - expect(patch.files).toContain(fwd(tmp.path, "tracked.txt")) - - // Ignored files should NOT be in snapshot - expect(patch.files).not.toContain(fwd(tmp.path, "new.ignored")) - expect(patch.files).not.toContain(fwd(tmp.path, "ignored.ignored")) - expect(patch.files).not.toContain(fwd(tmp.path, "build/output.js")) - expect(patch.files).not.toContain(fwd(tmp.path, "build/new-build.js")) - }, - }) -}) - -test("gitignore changes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/.gitignore`, "*.ignored") - await Filesystem.write(`${tmp.path}/test.ignored`, "ignored content") - await Filesystem.write(`${tmp.path}/normal.txt`, "normal content") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - - // Should track gitignore itself +it.instance( + "gitignore changes", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/.gitignore`, "*.ignored") + yield* write(`${tmp.path}/test.ignored`, "ignored content") + yield* write(`${tmp.path}/normal.txt`, "normal content") + const patch = yield* snapshot.patch(before) expect(patch.files).toContain(fwd(tmp.path, ".gitignore")) - // Should track normal files expect(patch.files).toContain(fwd(tmp.path, "normal.txt")) - // Should not track ignored files (git won't see them) expect(patch.files).not.toContain(fwd(tmp.path, "test.ignored")) - }, - }) -}) + }), + ), + { git: true }, +) -test("files tracked in snapshot but now gitignored are filtered out", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // First, create a file and snapshot it - await Filesystem.write(`${tmp.path}/later-ignored.txt`, "initial content") - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +it.instance( + "files tracked in snapshot but now gitignored are filtered out", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + yield* write(`${tmp.path}/later-ignored.txt`, "initial content") + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${tmp.path}/later-ignored.txt`, "modified content") + yield* write(`${tmp.path}/.gitignore`, "later-ignored.txt\n") + yield* write(`${tmp.path}/still-tracked.txt`, "new tracked file") + const patch = yield* snapshot.patch(before!) + expect(patch.files).not.toContain(fwd(tmp.path, "later-ignored.txt")) + expect(patch.files).toContain(fwd(tmp.path, ".gitignore")) + expect(patch.files).toContain(fwd(tmp.path, "still-tracked.txt")) + }), + { git: true }, +) - // Modify the file (so it appears in diff-files) - await Filesystem.write(`${tmp.path}/later-ignored.txt`, "modified content") - - // Now add gitignore that would exclude this file - await Filesystem.write(`${tmp.path}/.gitignore`, "later-ignored.txt\n") - - // Also create another tracked file - await Filesystem.write(`${tmp.path}/still-tracked.txt`, "new tracked file") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) - - // The file that is now gitignored should NOT appear, even though it was - // previously tracked and modified - expect(patch.files).not.toContain(fwd(tmp.path, "later-ignored.txt")) - - // The gitignore file itself should appear - expect(patch.files).toContain(fwd(tmp.path, ".gitignore")) - - // Other tracked files should appear - expect(patch.files).toContain(fwd(tmp.path, "still-tracked.txt")) - }, - }) -}) - -test("gitignore updated between track calls filters from diff", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // a.txt is already committed from bootstrap - track it in snapshot - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - // Modify a.txt (so it appears in diff-files) - await Filesystem.write(`${tmp.path}/a.txt`, "modified content") - - // Now add gitignore that would exclude a.txt - await Filesystem.write(`${tmp.path}/.gitignore`, "a.txt\n") - - // Also modify b.txt which is not gitignored - await Filesystem.write(`${tmp.path}/b.txt`, "also modified") - - // Second track - should not include a.txt even though it changed - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "gitignore updated between track calls filters from diff", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/a.txt`, "modified content") + yield* write(`${tmp.path}/.gitignore`, "a.txt\n") + yield* write(`${tmp.path}/b.txt`, "also modified") + const after = yield* snapshot.track() expect(after).toBeTruthy() - - // Verify a.txt is NOT in the diff between snapshots - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.some((x) => x.file === "a.txt")).toBe(false) - - // But .gitignore should be in the diff expect(diffs.some((x) => x.file === ".gitignore")).toBe(true) - - // b.txt should be in the diff (not gitignored) expect(diffs.some((x) => x.file === "b.txt")).toBe(true) - }, - }) -}) - -test("git info exclude changes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() + }), + ), + { git: true }, +) +it.instance( + "git info exclude changes", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { const file = `${tmp.path}/.git/info/exclude` - const text = await Bun.file(file).text() - await Bun.write(file, `${text.trimEnd()}\nignored.txt\n`) - await Bun.write(`${tmp.path}/ignored.txt`, "ignored content") - await Bun.write(`${tmp.path}/normal.txt`, "normal content") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) + yield* write(file, `${(yield* Effect.promise(() => Bun.file(file).text())).trimEnd()}\nignored.txt\n`) + yield* write(`${tmp.path}/ignored.txt`, "ignored content") + yield* write(`${tmp.path}/normal.txt`, "normal content") + const patch = yield* snapshot.patch(before) expect(patch.files).toContain(fwd(tmp.path, "normal.txt")) expect(patch.files).not.toContain(fwd(tmp.path, "ignored.txt")) - - const after = await run(tmp.path, (snapshot) => snapshot.track()) - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const after = yield* snapshot.track() + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.some((x) => x.file === "normal.txt")).toBe(true) expect(diffs.some((x) => x.file === "ignored.txt")).toBe(false) - }, - }) -}) + }), + ), + { git: true }, +) -test("git info exclude keeps global excludes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const global = `${tmp.path}/global.ignore` - const config = `${tmp.path}/global.gitconfig` - await Bun.write(global, "global.tmp\n") - await Bun.write(config, `[core]\n\texcludesFile = ${global.replaceAll("\\", "/")}\n`) - - const prev = process.env.GIT_CONFIG_GLOBAL - process.env.GIT_CONFIG_GLOBAL = config - try { - const before = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "git info exclude keeps global excludes", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const global = `${tmp.path}/global.ignore` + const config = `${tmp.path}/global.gitconfig` + yield* write(global, "global.tmp\n") + yield* write(config, `[core]\n\texcludesFile = ${global.replaceAll("\\", "/")}\n`) + yield* withGitConfigGlobal( + config, + Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() expect(before).toBeTruthy() - const file = `${tmp.path}/.git/info/exclude` - const text = await Bun.file(file).text() - await Bun.write(file, `${text.trimEnd()}\ninfo.tmp\n`) - - await Bun.write(`${tmp.path}/global.tmp`, "global content") - await Bun.write(`${tmp.path}/info.tmp`, "info content") - await Bun.write(`${tmp.path}/normal.txt`, "normal content") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(before!)) + yield* write(file, `${(yield* Effect.promise(() => Bun.file(file).text())).trimEnd()}\ninfo.tmp\n`) + yield* write(`${tmp.path}/global.tmp`, "global content") + yield* write(`${tmp.path}/info.tmp`, "info content") + yield* write(`${tmp.path}/normal.txt`, "normal content") + const patch = yield* snapshot.patch(before!) expect(patch.files).toContain(fwd(tmp.path, "normal.txt")) expect(patch.files).not.toContain(fwd(tmp.path, "global.tmp")) expect(patch.files).not.toContain(fwd(tmp.path, "info.tmp")) - } finally { - if (prev) process.env.GIT_CONFIG_GLOBAL = prev - else delete process.env.GIT_CONFIG_GLOBAL - } - }, - }) -}) + }), + ) + }), + { git: true }, +) -test("concurrent file operations during patch", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - // Start creating files - const createPromise = (async () => { +it.instance( + "concurrent file operations during patch", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + const fiber = yield* Effect.gen(function* () { for (let i = 0; i < 10; i++) { - await Filesystem.write(`${tmp.path}/concurrent${i}.txt`, `concurrent${i}`) - // Small delay to simulate concurrent operations - await new Promise((resolve) => setTimeout(resolve, 1)) + yield* write(`${tmp.path}/concurrent${i}.txt`, `concurrent${i}`) + yield* Effect.sleep("1 millis") } - })() - - // Get patch while files are being created - const patchPromise = run(tmp.path, (snapshot) => snapshot.patch(before!)) - - await createPromise - const patch = await patchPromise - - // Should capture some or all of the concurrent files + }).pipe(Effect.forkScoped) + const patch = yield* snapshot.patch(before) + yield* Fiber.join(fiber) expect(patch.files.length).toBeGreaterThanOrEqual(0) - }, - }) -}) + }), + ), + { git: true }, +) -test("snapshot state isolation between projects", async () => { - // Test that different projects don't interfere with each other - await using tmp1 = await bootstrap() - await using tmp2 = await bootstrap() - - await WithInstance.provide({ - directory: tmp1.path, - fn: async () => { - const before1 = await run(tmp1.path, (snapshot) => snapshot.track()) - await Filesystem.write(`${tmp1.path}/project1.txt`, "project1 content") - const patch1 = await run(tmp1.path, (snapshot) => snapshot.patch(before1!)) +it.live( + "snapshot state isolation between projects", + Effect.gen(function* () { + const tmp1 = yield* bootstrapScoped() + const tmp2 = yield* bootstrapScoped() + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before1 = yield* snapshot.track() + yield* write(`${tmp1.path}/project1.txt`, "project1 content") + const patch1 = yield* snapshot.patch(before1!) expect(patch1.files).toContain(fwd(tmp1.path, "project1.txt")) - }, - }) - - await WithInstance.provide({ - directory: tmp2.path, - fn: async () => { - const before2 = await run(tmp2.path, (snapshot) => snapshot.track()) - await Filesystem.write(`${tmp2.path}/project2.txt`, "project2 content") - const patch2 = await run(tmp2.path, (snapshot) => snapshot.patch(before2!)) + }).pipe(provideInstance(tmp1.path)) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before2 = yield* snapshot.track() + yield* write(`${tmp2.path}/project2.txt`, "project2 content") + const patch2 = yield* snapshot.patch(before2!) expect(patch2.files).toContain(fwd(tmp2.path, "project2.txt")) + expect(patch2.files).not.toContain(fwd(tmp1.path, "project1.txt")) + }).pipe(provideInstance(tmp2.path)) + }), +) - // Ensure project1 files don't appear in project2 - expect(patch2.files).not.toContain(fwd(tmp1?.path ?? "", "project1.txt")) - }, - }) -}) - -test("patch detects changes in secondary worktree", async () => { - await using tmp = await bootstrap() - const worktreePath = `${tmp.path}-worktree` - await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() - - try { - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() - }, - }) - - await WithInstance.provide({ - directory: worktreePath, - fn: async () => { - const before = await run(worktreePath, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - const worktreeFile = fwd(worktreePath, "worktree.txt") - await Filesystem.write(worktreeFile, "worktree content") - - const patch = await run(worktreePath, (snapshot) => snapshot.patch(before!)) - expect(patch.files).toContain(worktreeFile) - }, - }) - } finally { - await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow() - await $`rm -rf ${worktreePath}`.quiet() - } -}) - -test("revert only removes files in invoking worktree", async () => { - await using tmp = await bootstrap() - const worktreePath = `${tmp.path}-worktree` - await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() - - try { - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() - }, - }) - const primaryFile = `${tmp.path}/worktree.txt` - await Filesystem.write(primaryFile, "primary content") - - await WithInstance.provide({ - directory: worktreePath, - fn: async () => { - const before = await run(worktreePath, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - const worktreeFile = fwd(worktreePath, "worktree.txt") - await Filesystem.write(worktreeFile, "worktree content") - - const patch = await run(worktreePath, (snapshot) => snapshot.patch(before!)) - await run(worktreePath, (snapshot) => snapshot.revert([patch])) - - expect( - await fs - .access(worktreeFile) - .then(() => true) - .catch(() => false), - ).toBe(false) - }, - }) - - expect(await fs.readFile(primaryFile, "utf-8")).toBe("primary content") - } finally { - await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow() - await $`rm -rf ${worktreePath}`.quiet() - await $`rm -f ${tmp.path}/worktree.txt`.quiet() - } -}) - -test("diff reports worktree-only/shared edits and ignores primary-only", async () => { - await using tmp = await bootstrap() - const worktreePath = `${tmp.path}-worktree` - await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() - - try { - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() - }, - }) - - await WithInstance.provide({ - directory: worktreePath, - fn: async () => { - const before = await run(worktreePath, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${worktreePath}/worktree-only.txt`, "worktree diff content") - await Filesystem.write(`${worktreePath}/shared.txt`, "worktree edit") - await Filesystem.write(`${tmp.path}/shared.txt`, "primary edit") - await Filesystem.write(`${tmp.path}/primary-only.txt`, "primary change") - - const diff = await run(worktreePath, (snapshot) => snapshot.diff(before!)) - expect(diff).toContain("worktree-only.txt") - expect(diff).toContain("shared.txt") - expect(diff).not.toContain("primary-only.txt") - }, - }) - } finally { - await $`git worktree remove --force ${worktreePath}`.cwd(tmp.path).quiet().nothrow() - await $`rm -rf ${worktreePath}`.quiet() - await $`rm -f ${tmp.path}/shared.txt`.quiet() - await $`rm -f ${tmp.path}/primary-only.txt`.quiet() - } -}) - -test("track with no changes returns same hash", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const hash1 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(hash1).toBeTruthy() - - // Track again with no changes - const hash2 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(hash2).toBe(hash1!) - - // Track again - const hash3 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(hash3).toBe(hash1!) - }, - }) -}) - -test("diff function with various changes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) +it.live( + "patch detects changes in secondary worktree", + Effect.gen(function* () { + const tmp = yield* bootstrapScoped() + const worktreePath = `${tmp.path}-worktree` + yield* exec(tmp.path, ["git", "worktree", "add", worktreePath, "HEAD"]) + yield* Effect.addFinalizer(() => cleanupWorktree(tmp.path, worktreePath)) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + expect(yield* snapshot.track()).toBeTruthy() + }).pipe(provideInstance(tmp.path)) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() expect(before).toBeTruthy() + const worktreeFile = fwd(worktreePath, "worktree.txt") + yield* write(worktreeFile, "worktree content") + expect((yield* snapshot.patch(before!)).files).toContain(worktreeFile) + }).pipe(provideInstance(worktreePath)) + }), +) - // Make various changes - await $`rm ${tmp.path}/a.txt`.quiet() - await Filesystem.write(`${tmp.path}/new.txt`, "new content") - await Filesystem.write(`${tmp.path}/b.txt`, "modified content") +it.live( + "revert only removes files in invoking worktree", + Effect.gen(function* () { + const tmp = yield* bootstrapScoped() + const worktreePath = `${tmp.path}-worktree` + const primaryFile = `${tmp.path}/worktree.txt` + yield* exec(tmp.path, ["git", "worktree", "add", worktreePath, "HEAD"]) + yield* Effect.addFinalizer(() => cleanupWorktree(tmp.path, worktreePath, [primaryFile])) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + expect(yield* snapshot.track()).toBeTruthy() + }).pipe(provideInstance(tmp.path)) + yield* write(primaryFile, "primary content") + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + const worktreeFile = fwd(worktreePath, "worktree.txt") + yield* write(worktreeFile, "worktree content") + const patch = yield* snapshot.patch(before!) + yield* snapshot.revert([patch]) + expect(yield* exists(worktreeFile)).toBe(false) + }).pipe(provideInstance(worktreePath)) + expect(yield* readText(primaryFile)).toBe("primary content") + }), +) - const diff = await run(tmp.path, (snapshot) => snapshot.diff(before!)) +it.live( + "diff reports worktree-only/shared edits and ignores primary-only", + Effect.gen(function* () { + const tmp = yield* bootstrapScoped() + const worktreePath = `${tmp.path}-worktree` + yield* exec(tmp.path, ["git", "worktree", "add", worktreePath, "HEAD"]) + yield* Effect.addFinalizer(() => + cleanupWorktree(tmp.path, worktreePath, [`${tmp.path}/shared.txt`, `${tmp.path}/primary-only.txt`]), + ) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + expect(yield* snapshot.track()).toBeTruthy() + }).pipe(provideInstance(tmp.path)) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${worktreePath}/worktree-only.txt`, "worktree diff content") + yield* write(`${worktreePath}/shared.txt`, "worktree edit") + yield* write(`${tmp.path}/shared.txt`, "primary edit") + yield* write(`${tmp.path}/primary-only.txt`, "primary change") + const diff = yield* snapshot.diff(before!) + expect(diff).toContain("worktree-only.txt") + expect(diff).toContain("shared.txt") + expect(diff).not.toContain("primary-only.txt") + }).pipe(provideInstance(worktreePath)) + }), +) + +it.instance( + "track with no changes returns same hash", + withTrackedSnapshot(({ snapshot, before }) => + Effect.gen(function* () { + expect(yield* snapshot.track()).toBe(before) + expect(yield* snapshot.track()).toBe(before) + }), + ), + { git: true }, +) + +it.instance( + "diff function with various changes", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* rm(`${tmp.path}/a.txt`) + yield* write(`${tmp.path}/new.txt`, "new content") + yield* write(`${tmp.path}/b.txt`, "modified content") + const diff = yield* snapshot.diff(before) expect(diff).toContain("a.txt") expect(diff).toContain("b.txt") expect(diff).toContain("new.txt") - }, - }) -}) + }), + ), + { git: true }, +) -test("restore function", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +it.instance( + "restore function", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* rm(`${tmp.path}/a.txt`) + yield* write(`${tmp.path}/new.txt`, "new content") + yield* write(`${tmp.path}/b.txt`, "modified") + yield* snapshot.restore(before) + expect(yield* exists(`${tmp.path}/a.txt`)).toBe(true) + expect(yield* readText(`${tmp.path}/a.txt`)).toBe(tmp.extra.aContent) + expect(yield* exists(`${tmp.path}/new.txt`)).toBe(true) + expect(yield* readText(`${tmp.path}/b.txt`)).toBe(tmp.extra.bContent) + }), + ), + { git: true }, +) - // Make changes - await $`rm ${tmp.path}/a.txt`.quiet() - await Filesystem.write(`${tmp.path}/new.txt`, "new content") - await Filesystem.write(`${tmp.path}/b.txt`, "modified") +it.instance( + "revert should not delete files that existed but were deleted in snapshot", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + const snapshot1 = yield* snapshot.track() + expect(snapshot1).toBeTruthy() + yield* rm(`${tmp.path}/a.txt`) + const snapshot2 = yield* snapshot.track() + expect(snapshot2).toBeTruthy() + yield* write(`${tmp.path}/a.txt`, "recreated content") + const patch = yield* snapshot.patch(snapshot2!) + expect(patch.files).toContain(fwd(tmp.path, "a.txt")) + yield* snapshot.revert([patch]) + expect(yield* exists(`${tmp.path}/a.txt`)).toBe(false) + }), + { git: true }, +) - // Restore to original state - await run(tmp.path, (snapshot) => snapshot.restore(before!)) +it.instance( + "revert preserves file that existed in snapshot when deleted then recreated", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + yield* write(`${tmp.path}/existing.txt`, "original content") + const hash = yield* snapshot.track() + expect(hash).toBeTruthy() + yield* rm(`${tmp.path}/existing.txt`) + yield* write(`${tmp.path}/existing.txt`, "recreated") + yield* write(`${tmp.path}/newfile.txt`, "new") + const patch = yield* snapshot.patch(hash!) + expect(patch.files).toContain(fwd(tmp.path, "existing.txt")) + expect(patch.files).toContain(fwd(tmp.path, "newfile.txt")) + yield* snapshot.revert([patch]) + expect(yield* exists(`${tmp.path}/newfile.txt`)).toBe(false) + expect(yield* exists(`${tmp.path}/existing.txt`)).toBe(true) + expect(yield* readText(`${tmp.path}/existing.txt`)).toBe("original content") + }), + { git: true }, +) - expect( - await fs - .access(`${tmp.path}/a.txt`) - .then(() => true) - .catch(() => false), - ).toBe(true) - expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe(tmp.extra.aContent) - expect( - await fs - .access(`${tmp.path}/new.txt`) - .then(() => true) - .catch(() => false), - ).toBe(true) // New files should remain - expect(await fs.readFile(`${tmp.path}/b.txt`, "utf-8")).toBe(tmp.extra.bContent) - }, - }) -}) +it.instance( + "diffFull sets status based on git change type", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + yield* write(`${tmp.path}/grow.txt`, "one\n") + yield* write(`${tmp.path}/trim.txt`, "line1\nline2\n") + yield* write(`${tmp.path}/delete.txt`, "gone") + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${tmp.path}/grow.txt`, "one\ntwo\n") + yield* write(`${tmp.path}/trim.txt`, "line1\n") + yield* rm(`${tmp.path}/delete.txt`) + yield* write(`${tmp.path}/added.txt`, "new") + const after = yield* snapshot.track() + expect(after).toBeTruthy() + const diffs = yield* snapshot.diffFull(before!, after!) + expect(diffs.length).toBe(4) + expect(diffs.find((d) => d.file === "added.txt")!.status).toBe("added") + expect(diffs.find((d) => d.file === "delete.txt")!.status).toBe("deleted") + const grow = diffs.find((d) => d.file === "grow.txt")! + expect(grow.status).toBe("modified") + expect(grow.additions).toBeGreaterThan(0) + expect(grow.deletions).toBe(0) + const trim = diffs.find((d) => d.file === "trim.txt")! + expect(trim.status).toBe("modified") + expect(trim.additions).toBe(0) + expect(trim.deletions).toBeGreaterThan(0) + }), + { git: true }, +) -test("revert should not delete files that existed but were deleted in snapshot", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const snapshot1 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(snapshot1).toBeTruthy() - - await $`rm ${tmp.path}/a.txt`.quiet() - - const snapshot2 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(snapshot2).toBeTruthy() - - await Filesystem.write(`${tmp.path}/a.txt`, "recreated content") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(snapshot2!)) - expect(patch.files).toContain(fwd(tmp.path, "a.txt")) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - expect( - await fs - .access(`${tmp.path}/a.txt`) - .then(() => true) - .catch(() => false), - ).toBe(false) - }, - }) -}) - -test("revert preserves file that existed in snapshot when deleted then recreated", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await Filesystem.write(`${tmp.path}/existing.txt`, "original content") - - const hash = await run(tmp.path, (snapshot) => snapshot.track()) - expect(hash).toBeTruthy() - - await $`rm ${tmp.path}/existing.txt`.quiet() - await Filesystem.write(`${tmp.path}/existing.txt`, "recreated") - await Filesystem.write(`${tmp.path}/newfile.txt`, "new") - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(hash!)) - expect(patch.files).toContain(fwd(tmp.path, "existing.txt")) - expect(patch.files).toContain(fwd(tmp.path, "newfile.txt")) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - expect( - await fs - .access(`${tmp.path}/newfile.txt`) - .then(() => true) - .catch(() => false), - ).toBe(false) - expect( - await fs - .access(`${tmp.path}/existing.txt`) - .then(() => true) - .catch(() => false), - ).toBe(true) - expect(await fs.readFile(`${tmp.path}/existing.txt`, "utf-8")).toBe("original content") - }, - }) -}) - -test("diffFull sets status based on git change type", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await Filesystem.write(`${tmp.path}/grow.txt`, "one\n") - await Filesystem.write(`${tmp.path}/trim.txt`, "line1\nline2\n") - await Filesystem.write(`${tmp.path}/delete.txt`, "gone") - - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/grow.txt`, "one\ntwo\n") - await Filesystem.write(`${tmp.path}/trim.txt`, "line1\n") - await $`rm ${tmp.path}/delete.txt`.quiet() - await Filesystem.write(`${tmp.path}/added.txt`, "new") - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with new file additions", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/new.txt`, "new content") + const after = yield* snapshot.track() expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) - expect(diffs.length).toBe(4) - - const added = diffs.find((d) => d.file === "added.txt") - expect(added).toBeDefined() - expect(added!.status).toBe("added") - - const deleted = diffs.find((d) => d.file === "delete.txt") - expect(deleted).toBeDefined() - expect(deleted!.status).toBe("deleted") - - const grow = diffs.find((d) => d.file === "grow.txt") - expect(grow).toBeDefined() - expect(grow!.status).toBe("modified") - expect(grow!.additions).toBeGreaterThan(0) - expect(grow!.deletions).toBe(0) - - const trim = diffs.find((d) => d.file === "trim.txt") - expect(trim).toBeDefined() - expect(trim!.status).toBe("modified") - expect(trim!.additions).toBe(0) - expect(trim!.deletions).toBeGreaterThan(0) - }, - }) -}) - -test("diffFull with new file additions", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/new.txt`, "new content") - - const after = await run(tmp.path, (snapshot) => snapshot.track()) - expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.length).toBe(1) + expect(diffs[0].file).toBe("new.txt") + expect(diffs[0].patch).toContain("+new content") + expect(diffs[0].additions).toBe(1) + expect(diffs[0].deletions).toBe(0) + }), + ), + { git: true }, +) - const newFileDiff = diffs[0] - expect(newFileDiff.file).toBe("new.txt") - expect(newFileDiff.patch).toContain("+new content") - expect(newFileDiff.additions).toBe(1) - expect(newFileDiff.deletions).toBe(0) - }, - }) -}) +it.instance( + "diffFull with a large interleaved mixed diff", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + const ids = Array.from({ length: 60 }, (_, i) => i.toString().padStart(3, "0")) + const mod = ids.map((id) => fwd(tmp.path, "mix", `${id}-mod.txt`)) + const del = ids.map((id) => fwd(tmp.path, "mix", `${id}-del.txt`)) + const add = ids.map((id) => fwd(tmp.path, "mix", `${id}-add.txt`)) + const bin = ids.map((id) => fwd(tmp.path, "mix", `${id}-bin.bin`)) + yield* mkdirp(`${tmp.path}/mix`) + yield* Effect.all( + [ + ...mod.map((file, i) => write(file, `before-${ids[i]}-é\n🙂\nline`)), + ...del.map((file, i) => write(file, `gone-${ids[i]}\n你好`)), + ...bin.map((file, i) => write(file, new Uint8Array([0, i, 255, i % 251]))), + ], + { concurrency: "unbounded" }, + ) + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* Effect.all( + [ + ...mod.map((file, i) => write(file, `after-${ids[i]}-é\n🚀\nline`)), + ...add.map((file, i) => write(file, `new-${ids[i]}\nこんにちは`)), + ...bin.map((file, i) => write(file, new Uint8Array([9, i, 8, i % 251]))), + ...del.map((file) => rm(file)), + ], + { concurrency: "unbounded" }, + ) + const after = yield* snapshot.track() + expect(after).toBeTruthy() + const diffs = yield* snapshot.diffFull(before!, after!) + expect(diffs).toHaveLength(ids.length * 4) + const map = new Map(diffs.map((item) => [item.file, item])) + for (let i = 0; i < ids.length; i++) { + const m = map.get(fwd("mix", `${ids[i]}-mod.txt`)) + expect(m).toBeDefined() + expect(m!.patch).toContain(`-before-${ids[i]}-é`) + expect(m!.patch).toContain(`+after-${ids[i]}-é`) + expect(m!.status).toBe("modified") + const d = map.get(fwd("mix", `${ids[i]}-del.txt`)) + expect(d).toBeDefined() + expect(d!.patch).toContain(`-gone-${ids[i]}`) + expect(d!.status).toBe("deleted") + const a = map.get(fwd("mix", `${ids[i]}-add.txt`)) + expect(a).toBeDefined() + expect(a!.patch).toContain(`+new-${ids[i]}`) + expect(a!.status).toBe("added") + const b = map.get(fwd("mix", `${ids[i]}-bin.bin`)) + expect(b).toBeDefined() + expect(b!.patch).toBe("") + expect(b!.additions).toBe(0) + expect(b!.deletions).toBe(0) + expect(b!.status).toBe("modified") + } + }), + { git: true }, +) -test("diffFull with a large interleaved mixed diff", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const ids = Array.from({ length: 60 }, (_, i) => i.toString().padStart(3, "0")) - const mod = ids.map((id) => fwd(tmp.path, "mix", `${id}-mod.txt`)) - const del = ids.map((id) => fwd(tmp.path, "mix", `${id}-del.txt`)) - const add = ids.map((id) => fwd(tmp.path, "mix", `${id}-add.txt`)) - const bin = ids.map((id) => fwd(tmp.path, "mix", `${id}-bin.bin`)) +it.instance( + "diffFull preserves git diff order across batch boundaries", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + const ids = Array.from({ length: 140 }, (_, i) => i.toString().padStart(3, "0")) + yield* mkdirp(`${tmp.path}/order`) + yield* Effect.all( + ids.map((id) => write(`${tmp.path}/order/${id}.txt`, `before-${id}`)), + { concurrency: "unbounded" }, + ) + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* Effect.all( + ids.map((id) => write(`${tmp.path}/order/${id}.txt`, `after-${id}`)), + { concurrency: "unbounded" }, + ) + const after = yield* snapshot.track() + expect(after).toBeTruthy() + expect((yield* snapshot.diffFull(before!, after!)).map((item) => item.file)).toEqual( + ids.map((id) => `order/${id}.txt`), + ) + }), + { git: true }, +) - await $`mkdir -p ${tmp.path}/mix`.quiet() - await Promise.all([ - ...mod.map((file, i) => Filesystem.write(file, `before-${ids[i]}-é\n🙂\nline`)), - ...del.map((file, i) => Filesystem.write(file, `gone-${ids[i]}\n你好`)), - ...bin.map((file, i) => Filesystem.write(file, new Uint8Array([0, i, 255, i % 251]))), - ]) - - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Promise.all([ - ...mod.map((file, i) => Filesystem.write(file, `after-${ids[i]}-é\n🚀\nline`)), - ...add.map((file, i) => Filesystem.write(file, `new-${ids[i]}\nこんにちは`)), - ...bin.map((file, i) => Filesystem.write(file, new Uint8Array([9, i, 8, i % 251]))), - ...del.map((file) => fs.rm(file)), - ]) - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with file modifications", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/b.txt`, "modified content") + const after = yield* snapshot.track() expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) - expect(diffs).toHaveLength(ids.length * 4) - - const map = new Map(diffs.map((item) => [item.file, item])) - for (let i = 0; i < ids.length; i++) { - const m = map.get(fwd("mix", `${ids[i]}-mod.txt`)) - expect(m).toBeDefined() - expect(m!.patch).toContain(`-before-${ids[i]}-é`) - expect(m!.patch).toContain(`+after-${ids[i]}-é`) - expect(m!.status).toBe("modified") - - const d = map.get(fwd("mix", `${ids[i]}-del.txt`)) - expect(d).toBeDefined() - expect(d!.patch).toContain(`-gone-${ids[i]}`) - expect(d!.status).toBe("deleted") - - const a = map.get(fwd("mix", `${ids[i]}-add.txt`)) - expect(a).toBeDefined() - expect(a!.patch).toContain(`+new-${ids[i]}`) - expect(a!.status).toBe("added") - - const b = map.get(fwd("mix", `${ids[i]}-bin.bin`)) - expect(b).toBeDefined() - expect(b!.patch).toBe("") - expect(b!.additions).toBe(0) - expect(b!.deletions).toBe(0) - expect(b!.status).toBe("modified") - } - }, - }) -}) - -test("diffFull preserves git diff order across batch boundaries", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const ids = Array.from({ length: 140 }, (_, i) => i.toString().padStart(3, "0")) - - await $`mkdir -p ${tmp.path}/order`.quiet() - await Promise.all(ids.map((id) => Filesystem.write(`${tmp.path}/order/${id}.txt`, `before-${id}`))) - - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Promise.all(ids.map((id) => Filesystem.write(`${tmp.path}/order/${id}.txt`, `after-${id}`))) - - const after = await run(tmp.path, (snapshot) => snapshot.track()) - expect(after).toBeTruthy() - - const expected = ids.map((id) => `order/${id}.txt`) - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) - expect(diffs.map((item) => item.file)).toEqual(expected) - }, - }) -}) - -test("diffFull with file modifications", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/b.txt`, "modified content") - - const after = await run(tmp.path, (snapshot) => snapshot.track()) - expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.length).toBe(1) + expect(diffs[0].file).toBe("b.txt") + expect(diffs[0].patch).toContain(`-${tmp.extra.bContent}`) + expect(diffs[0].patch).toContain("+modified content") + expect(diffs[0].additions).toBeGreaterThan(0) + expect(diffs[0].deletions).toBeGreaterThan(0) + }), + ), + { git: true }, +) - const modifiedFileDiff = diffs[0] - expect(modifiedFileDiff.file).toBe("b.txt") - expect(modifiedFileDiff.patch).toContain(`-${tmp.extra.bContent}`) - expect(modifiedFileDiff.patch).toContain("+modified content") - expect(modifiedFileDiff.additions).toBeGreaterThan(0) - expect(modifiedFileDiff.deletions).toBeGreaterThan(0) - }, - }) -}) - -test("diffFull with file deletions", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await $`rm ${tmp.path}/a.txt`.quiet() - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with file deletions", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* rm(`${tmp.path}/a.txt`) + const after = yield* snapshot.track() expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.length).toBe(1) + expect(diffs[0].file).toBe("a.txt") + expect(diffs[0].patch).toContain(`-${tmp.extra.aContent}`) + expect(diffs[0].additions).toBe(0) + expect(diffs[0].deletions).toBe(1) + }), + ), + { git: true }, +) - const removedFileDiff = diffs[0] - expect(removedFileDiff.file).toBe("a.txt") - expect(removedFileDiff.patch).toContain(`-${tmp.extra.aContent}`) - expect(removedFileDiff.additions).toBe(0) - expect(removedFileDiff.deletions).toBe(1) - }, - }) -}) - -test("diffFull with multiple line additions", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/multi.txt`, "line1\nline2\nline3") - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with multiple line additions", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/multi.txt`, "line1\nline2\nline3") + const after = yield* snapshot.track() expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.length).toBe(1) + expect(diffs[0].file).toBe("multi.txt") + expect(diffs[0].patch).toContain("+line1") + expect(diffs[0].patch).toContain("+line3") + expect(diffs[0].additions).toBe(3) + expect(diffs[0].deletions).toBe(0) + }), + ), + { git: true }, +) - const multiDiff = diffs[0] - expect(multiDiff.file).toBe("multi.txt") - expect(multiDiff.patch).toContain("+line1") - expect(multiDiff.patch).toContain("+line3") - expect(multiDiff.additions).toBe(3) - expect(multiDiff.deletions).toBe(0) - }, - }) -}) - -test("diffFull with addition and deletion", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/added.txt`, "added content") - await $`rm ${tmp.path}/a.txt`.quiet() - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with addition and deletion", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/added.txt`, "added content") + yield* rm(`${tmp.path}/a.txt`) + const after = yield* snapshot.track() expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.length).toBe(2) + const added = diffs.find((d) => d.file === "added.txt")! + expect(added.patch).toContain("+added content") + expect(added.additions).toBe(1) + expect(added.deletions).toBe(0) + const removed = diffs.find((d) => d.file === "a.txt")! + expect(removed.patch).toContain(`-${tmp.extra.aContent}`) + expect(removed.additions).toBe(0) + expect(removed.deletions).toBe(1) + }), + ), + { git: true }, +) - const addedFileDiff = diffs.find((d) => d.file === "added.txt") - expect(addedFileDiff).toBeDefined() - expect(addedFileDiff!.patch).toContain("+added content") - expect(addedFileDiff!.additions).toBe(1) - expect(addedFileDiff!.deletions).toBe(0) - - const removedFileDiff = diffs.find((d) => d.file === "a.txt") - expect(removedFileDiff).toBeDefined() - expect(removedFileDiff!.patch).toContain(`-${tmp.extra.aContent}`) - expect(removedFileDiff!.additions).toBe(0) - expect(removedFileDiff!.deletions).toBe(1) - }, - }) -}) - -test("diffFull with multiple additions and deletions", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/multi1.txt`, "line1\nline2\nline3") - await Filesystem.write(`${tmp.path}/multi2.txt`, "single line") - await $`rm ${tmp.path}/a.txt`.quiet() - await $`rm ${tmp.path}/b.txt`.quiet() - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with multiple additions and deletions", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/multi1.txt`, "line1\nline2\nline3") + yield* write(`${tmp.path}/multi2.txt`, "single line") + yield* rm(`${tmp.path}/a.txt`) + yield* rm(`${tmp.path}/b.txt`) + const after = yield* snapshot.track() expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.length).toBe(4) + expect(diffs.find((d) => d.file === "multi1.txt")!.additions).toBe(3) + expect(diffs.find((d) => d.file === "multi1.txt")!.deletions).toBe(0) + expect(diffs.find((d) => d.file === "multi2.txt")!.additions).toBe(1) + expect(diffs.find((d) => d.file === "multi2.txt")!.deletions).toBe(0) + expect(diffs.find((d) => d.file === "a.txt")!.additions).toBe(0) + expect(diffs.find((d) => d.file === "a.txt")!.deletions).toBe(1) + expect(diffs.find((d) => d.file === "b.txt")!.additions).toBe(0) + expect(diffs.find((d) => d.file === "b.txt")!.deletions).toBe(1) + }), + ), + { git: true }, +) - const multi1Diff = diffs.find((d) => d.file === "multi1.txt") - expect(multi1Diff).toBeDefined() - expect(multi1Diff!.additions).toBe(3) - expect(multi1Diff!.deletions).toBe(0) - - const multi2Diff = diffs.find((d) => d.file === "multi2.txt") - expect(multi2Diff).toBeDefined() - expect(multi2Diff!.additions).toBe(1) - expect(multi2Diff!.deletions).toBe(0) - - const removedADiff = diffs.find((d) => d.file === "a.txt") - expect(removedADiff).toBeDefined() - expect(removedADiff!.additions).toBe(0) - expect(removedADiff!.deletions).toBe(1) - - const removedBDiff = diffs.find((d) => d.file === "b.txt") - expect(removedBDiff).toBeDefined() - expect(removedBDiff!.additions).toBe(0) - expect(removedBDiff!.deletions).toBe(1) - }, - }) -}) - -test("diffFull with no changes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with no changes", + withTrackedSnapshot(({ snapshot, before }) => + Effect.gen(function* () { + const after = yield* snapshot.track() expect(after).toBeTruthy() + expect((yield* snapshot.diffFull(before, after!)).length).toBe(0) + }), + ), + { git: true }, +) - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) - expect(diffs.length).toBe(0) - }, - }) -}) - -test("diffFull with binary file changes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() - - await Filesystem.write(`${tmp.path}/binary.bin`, new Uint8Array([0x00, 0x01, 0x02, 0x03])) - - const after = await run(tmp.path, (snapshot) => snapshot.track()) +it.instance( + "diffFull with binary file changes", + withTrackedSnapshot(({ tmp, snapshot, before }) => + Effect.gen(function* () { + yield* write(`${tmp.path}/binary.bin`, new Uint8Array([0x00, 0x01, 0x02, 0x03])) + const after = yield* snapshot.track() expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) + const diffs = yield* snapshot.diffFull(before, after!) expect(diffs.length).toBe(1) + expect(diffs[0].file).toBe("binary.bin") + expect(diffs[0].patch).toBe("") + }), + ), + { git: true }, +) - const binaryDiff = diffs[0] - expect(binaryDiff.file).toBe("binary.bin") - expect(binaryDiff.patch).toBe("") - }, - }) -}) +it.instance( + "diffFull with whitespace changes", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + yield* write(`${tmp.path}/whitespace.txt`, "line1\nline2") + const before = yield* snapshot.track() + expect(before).toBeTruthy() + yield* write(`${tmp.path}/whitespace.txt`, "line1\n\nline2\n") + const after = yield* snapshot.track() + expect(after).toBeTruthy() + const diffs = yield* snapshot.diffFull(before!, after!) + expect(diffs.length).toBe(1) + expect(diffs[0].file).toBe("whitespace.txt") + expect(diffs[0].additions).toBeGreaterThan(0) + }), + { git: true }, +) -test("diffFull with whitespace changes", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\nline2") - const before = await run(tmp.path, (snapshot) => snapshot.track()) - expect(before).toBeTruthy() +it.instance( + "revert with overlapping files across patches uses first patch hash", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + yield* write(`${tmp.path}/shared.txt`, "v1") + const snap1 = yield* snapshot.track() + expect(snap1).toBeTruthy() + yield* write(`${tmp.path}/shared.txt`, "v2") + const snap2 = yield* snapshot.track() + expect(snap2).toBeTruthy() + yield* write(`${tmp.path}/shared.txt`, "v3") + const patch1 = yield* snapshot.patch(snap1!) + const patch2 = yield* snapshot.patch(snap2!) + expect(patch1.files).toContain(fwd(tmp.path, "shared.txt")) + expect(patch2.files).toContain(fwd(tmp.path, "shared.txt")) + yield* snapshot.revert([patch1, patch2]) + expect(yield* readText(`${tmp.path}/shared.txt`)).toBe("v1") + }), + { git: true }, +) - await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\n\nline2\n") +it.instance( + "revert preserves patch order when the same hash appears again", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + yield* mkdirp(`${tmp.path}/foo`) + yield* write(`${tmp.path}/foo/bar`, "v1") + yield* write(`${tmp.path}/a.txt`, "v1") + const snap1 = yield* snapshot.track() + expect(snap1).toBeTruthy() + yield* rm(`${tmp.path}/foo`) + yield* write(`${tmp.path}/foo`, "v2") + yield* write(`${tmp.path}/a.txt`, "v2") + const snap2 = yield* snapshot.track() + expect(snap2).toBeTruthy() + yield* rm(`${tmp.path}/foo`) + yield* write(`${tmp.path}/a.txt`, "v3") + yield* snapshot.revert([ + { hash: snap1!, files: [fwd(tmp.path, "a.txt")] }, + { hash: snap2!, files: [fwd(tmp.path, "foo")] }, + { hash: snap1!, files: [fwd(tmp.path, "foo", "bar")] }, + ]) + expect(yield* readText(`${tmp.path}/a.txt`)).toBe("v1") + expect((yield* Effect.promise(() => fs.stat(`${tmp.path}/foo`))).isDirectory()).toBe(true) + expect(yield* readText(`${tmp.path}/foo/bar`)).toBe("v1") + }), + { git: true }, +) - const after = await run(tmp.path, (snapshot) => snapshot.track()) - expect(after).toBeTruthy() - - const diffs = await run(tmp.path, (snapshot) => snapshot.diffFull(before!, after!)) - expect(diffs.length).toBe(1) - - const whitespaceDiff = diffs[0] - expect(whitespaceDiff.file).toBe("whitespace.txt") - expect(whitespaceDiff.additions).toBeGreaterThan(0) - }, - }) -}) - -test("revert with overlapping files across patches uses first patch hash", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - // Write initial content and snapshot - await Filesystem.write(`${tmp.path}/shared.txt`, "v1") - const snap1 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(snap1).toBeTruthy() - - // Modify and snapshot again - await Filesystem.write(`${tmp.path}/shared.txt`, "v2") - const snap2 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(snap2).toBeTruthy() - - // Modify once more so both patches include shared.txt - await Filesystem.write(`${tmp.path}/shared.txt`, "v3") - - const patch1 = await run(tmp.path, (snapshot) => snapshot.patch(snap1!)) - const patch2 = await run(tmp.path, (snapshot) => snapshot.patch(snap2!)) - - // Both patches should include shared.txt - expect(patch1.files).toContain(fwd(tmp.path, "shared.txt")) - expect(patch2.files).toContain(fwd(tmp.path, "shared.txt")) - - // Revert with patch1 first — should use snap1's hash (restoring "v1") - await run(tmp.path, (snapshot) => snapshot.revert([patch1, patch2])) - - const content = await fs.readFile(`${tmp.path}/shared.txt`, "utf-8") - expect(content).toBe("v1") - }, - }) -}) - -test("revert preserves patch order when the same hash appears again", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - await $`mkdir -p ${tmp.path}/foo`.quiet() - await Filesystem.write(`${tmp.path}/foo/bar`, "v1") - await Filesystem.write(`${tmp.path}/a.txt`, "v1") - - const snap1 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(snap1).toBeTruthy() - - await $`rm -rf ${tmp.path}/foo`.quiet() - await Filesystem.write(`${tmp.path}/foo`, "v2") - await Filesystem.write(`${tmp.path}/a.txt`, "v2") - - const snap2 = await run(tmp.path, (snapshot) => snapshot.track()) - expect(snap2).toBeTruthy() - - await $`rm -rf ${tmp.path}/foo`.quiet() - await Filesystem.write(`${tmp.path}/a.txt`, "v3") - - await run(tmp.path, (snapshot) => - snapshot.revert([ - { hash: snap1!, files: [fwd(tmp.path, "a.txt")] }, - { hash: snap2!, files: [fwd(tmp.path, "foo")] }, - { hash: snap1!, files: [fwd(tmp.path, "foo", "bar")] }, - ]), - ) - - expect(await fs.readFile(`${tmp.path}/a.txt`, "utf-8")).toBe("v1") - expect((await fs.stat(`${tmp.path}/foo`)).isDirectory()).toBe(true) - expect(await fs.readFile(`${tmp.path}/foo/bar`, "utf-8")).toBe("v1") - }, - }) -}) - -test("revert handles large mixed batches across chunk boundaries", async () => { - await using tmp = await bootstrap() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const base = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "batch", `${i}.txt`)) - const fresh = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "fresh", `${i}.txt`)) - - await $`mkdir -p ${tmp.path}/batch ${tmp.path}/fresh`.quiet() - await Promise.all(base.map((file, i) => Filesystem.write(file, `base-${i}`))) - - const snap = await run(tmp.path, (snapshot) => snapshot.track()) - expect(snap).toBeTruthy() - - await Promise.all(base.map((file, i) => Filesystem.write(file, `next-${i}`))) - await Promise.all(fresh.map((file, i) => Filesystem.write(file, `fresh-${i}`))) - - const patch = await run(tmp.path, (snapshot) => snapshot.patch(snap!)) - expect(patch.files.length).toBe(base.length + fresh.length) - - await run(tmp.path, (snapshot) => snapshot.revert([patch])) - - await Promise.all( - base.map(async (file, i) => { - expect(await fs.readFile(file, "utf-8")).toBe(`base-${i}`) - }), - ) - - await Promise.all( - fresh.map(async (file) => { - expect( - await fs - .access(file) - .then(() => true) - .catch(() => false), - ).toBe(false) - }), - ) - }, - }) -}) +it.instance( + "revert handles large mixed batches across chunk boundaries", + Effect.gen(function* () { + const tmp = yield* bootstrap() + const snapshot = yield* Snapshot.Service + const base = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "batch", `${i}.txt`)) + const fresh = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "fresh", `${i}.txt`)) + yield* mkdirp(`${tmp.path}/batch`) + yield* mkdirp(`${tmp.path}/fresh`) + yield* Effect.all( + base.map((file, i) => write(file, `base-${i}`)), + { concurrency: "unbounded" }, + ) + const snap = yield* snapshot.track() + expect(snap).toBeTruthy() + yield* Effect.all( + [...base.map((file, i) => write(file, `next-${i}`)), ...fresh.map((file, i) => write(file, `fresh-${i}`))], + { concurrency: "unbounded" }, + ) + const patch = yield* snapshot.patch(snap!) + expect(patch.files.length).toBe(base.length + fresh.length) + yield* snapshot.revert([patch]) + for (let i = 0; i < base.length; i++) expect(yield* readText(base[i])).toBe(`base-${i}`) + for (const file of fresh) expect(yield* exists(file)).toBe(false) + }), + { git: true }, +) From c4003579bbe6d943562814686a0cdd5a1357f784 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 20:59:34 -0400 Subject: [PATCH 13/32] test(project): migrate VCS tests to Effect runner (#26965) --- packages/opencode/test/project/vcs.test.ts | 527 ++++++++++----------- 1 file changed, 252 insertions(+), 275 deletions(-) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 82eacfb6df..75d1feadd0 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,161 +1,139 @@ -import { $ } from "bun" -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect } from "bun:test" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { parsePatch } from "diff" -import { Effect } from "effect" +import { Deferred, Effect, Layer, Stream } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import fs from "fs/promises" import path from "path" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { AppRuntime } from "../../src/effect/app-runtime" +import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { Bus } from "../../src/bus" import { FileWatcher } from "../../src/file/watcher" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { GlobalBus } from "../../src/bus/global" +import { Git } from "../../src/git" import { Vcs } from "@/project/vcs" - -// Skip in CI — native @parcel/watcher binding needed -const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip +import { testEffect } from "../lib/effect" // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -async function withVcs(directory: string, body: () => Promise) { - return WithInstance.provide({ - directory, - fn: async () => { - await AppRuntime.runPromise( - Effect.gen(function* () { - const watcher = yield* FileWatcher.Service - const vcs = yield* Vcs.Service - yield* watcher.init() - yield* vcs.init() - }), - ) - await Bun.sleep(500) - await body() - }, - }) -} - -function withVcsOnly(directory: string, body: () => Promise) { - return WithInstance.provide({ - directory, - fn: async () => { - await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - yield* vcs.init() - }), - ) - await body() - }, - }) -} - -type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } } const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" -/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */ -function nextBranchUpdate(directory: string, timeout = 10_000) { - return new Promise((resolve, reject) => { - let settled = false +const layer = Layer.mergeAll( + Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(Bus.layer)), + CrossSpawnSpawner.defaultLayer, + AppFileSystem.defaultLayer, +) +const it = testEffect(layer) - const timer = setTimeout(() => { - if (settled) return - settled = true - GlobalBus.off("event", on) - reject(new Error("timed out waiting for BranchUpdated event")) - }, timeout) +const git = Effect.fn("VcsTest.git")(function* (cwd: string, args: string[]) { + const result = yield* Git.Service.use((git) => git.run(args, { cwd })) + if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString("utf8")}`) +}) - function on(evt: BranchEvent) { - if (evt.directory !== directory) return - if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return - if (settled) return - settled = true - clearTimeout(timer) - GlobalBus.off("event", on) - resolve(evt.payload.properties.branch) - } +const write = Effect.fn("VcsTest.write")(function* (file: string, content: string) { + yield* AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content)) +}) - GlobalBus.on("event", on) - }) -} +const remove = Effect.fn("VcsTest.remove")(function* (file: string) { + yield* AppFileSystem.Service.use((fs) => fs.remove(file)) +}) + +const symlink = (target: string, file: string) => Effect.promise(() => fs.symlink(target, file)) + +const init = Effect.fn("VcsTest.init")(function* () { + const vcs = yield* Vcs.Service + yield* vcs.init() + return vcs +}) + +const nextBranchUpdate = Effect.fn("VcsTest.nextBranchUpdate")(function* () { + const bus = yield* Bus.Service + const updated = yield* Deferred.make() + + yield* Stream.runForEach(bus.subscribe(Vcs.Event.BranchUpdated), (evt) => + Deferred.succeed(updated, evt.properties.branch), + ).pipe(Effect.forkScoped) + + return updated +}) // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describeVcs("Vcs", () => { +describe("Vcs", () => { afterEach(async () => { await disposeAllInstances() }) - test("branch() returns current branch name", async () => { - await using tmp = await tmpdir({ git: true }) + it.instance( + "branch() returns current branch name", + () => + Effect.gen(function* () { + const vcs = yield* init() + const branch = yield* vcs.branch() - await withVcs(tmp.path, async () => { - const branch = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.branch() - }), - ) - expect(branch).toBeDefined() - expect(typeof branch).toBe("string") - }) - }) + expect(branch).toBeDefined() + expect(typeof branch).toBe("string") + }), + { git: true }, + ) - test("branch() returns undefined for non-git directories", async () => { - await using tmp = await tmpdir() + it.instance("branch() returns undefined for non-git directories", () => + Effect.gen(function* () { + const vcs = yield* init() + const branch = yield* vcs.branch() - await withVcs(tmp.path, async () => { - const branch = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.branch() - }), - ) expect(branch).toBeUndefined() - }) - }) + }), + ) - test("publishes BranchUpdated when .git/HEAD changes", async () => { - await using tmp = await tmpdir({ git: true }) - const branch = `test-${Math.random().toString(36).slice(2)}` - await $`git branch ${branch}`.cwd(tmp.path).quiet() + it.instance( + "publishes BranchUpdated when .git/HEAD changes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const branch = `test-${Math.random().toString(36).slice(2)}` + yield* git(test.directory, ["branch", branch]) - await withVcs(tmp.path, async () => { - const pending = nextBranchUpdate(tmp.path) + const vcs = yield* init() + yield* vcs.branch() + const pending = yield* nextBranchUpdate() + const bus = yield* Bus.Service - const head = path.join(tmp.path, ".git", "HEAD") - await fs.writeFile(head, `ref: refs/heads/${branch}\n`) + const head = path.join(test.directory, ".git", "HEAD") + yield* write(head, `ref: refs/heads/${branch}\n`) + yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" }) - const updated = await pending - expect(updated).toBe(branch) - }) - }) + const updated = yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds")) + expect(updated).toBe(branch) + }), + { git: true }, + ) - test("branch() reflects the new branch after HEAD change", async () => { - await using tmp = await tmpdir({ git: true }) - const branch = `test-${Math.random().toString(36).slice(2)}` - await $`git branch ${branch}`.cwd(tmp.path).quiet() + it.instance( + "branch() reflects the new branch after HEAD change", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const branch = `test-${Math.random().toString(36).slice(2)}` + yield* git(test.directory, ["branch", branch]) - await withVcs(tmp.path, async () => { - const pending = nextBranchUpdate(tmp.path) + const vcs = yield* init() + yield* vcs.branch() + const pending = yield* nextBranchUpdate() + const bus = yield* Bus.Service - const head = path.join(tmp.path, ".git", "HEAD") - await fs.writeFile(head, `ref: refs/heads/${branch}\n`) + const head = path.join(test.directory, ".git", "HEAD") + yield* write(head, `ref: refs/heads/${branch}\n`) + yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" }) + yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds")) - await pending - const current = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.branch() - }), - ) - expect(current).toBe(branch) - }) - }) + const current = yield* vcs.branch() + expect(current).toBe(branch) + }), + { git: true }, + ) }) describe("Vcs diff", () => { @@ -163,177 +141,176 @@ describe("Vcs diff", () => { await disposeAllInstances() }) - test("defaultBranch() falls back to main", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() + it.instance( + "defaultBranch() falls back to main", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* git(test.directory, ["branch", "-M", "main"]) - await withVcsOnly(tmp.path, async () => { - const branch = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.defaultBranch() - }), - ) - expect(branch).toBe("main") - }) - }) + const vcs = yield* init() + const branch = yield* vcs.defaultBranch() - test("defaultBranch() uses init.defaultBranch when available", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M trunk`.cwd(tmp.path).quiet() - await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() + expect(branch).toBe("main") + }), + { git: true }, + ) - await withVcsOnly(tmp.path, async () => { - const branch = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.defaultBranch() - }), - ) - expect(branch).toBe("trunk") - }) - }) + it.instance( + "defaultBranch() uses init.defaultBranch when available", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* git(test.directory, ["branch", "-M", "trunk"]) + yield* git(test.directory, ["config", "init.defaultBranch", "trunk"]) - test("detects current branch from the active worktree", async () => { - await using tmp = await tmpdir({ git: true }) - await using wt = await tmpdir() - await $`git branch -M main`.cwd(tmp.path).quiet() - const dir = path.join(wt.path, "feature") - await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet() + const vcs = yield* init() + const branch = yield* vcs.defaultBranch() - await withVcsOnly(dir, async () => { - const [branch, base] = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 }) - }), - ) + expect(branch).toBe("trunk") + }), + { git: true }, + ) + + it.live("detects current branch from the active worktree", () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped({ git: true }) + const wt = yield* tmpdirScoped() + yield* git(tmp, ["branch", "-M", "main"]) + const dir = path.join(wt, "feature") + yield* git(tmp, ["worktree", "add", "-b", "feature/test", dir, "HEAD"]) + + const [branch, base] = yield* Effect.gen(function* () { + const vcs = yield* init() + return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 }) + }).pipe(provideInstance(dir)) + + expect(branch).toBeDefined() expect(branch).toBe("feature/test") expect(base).toBe("main") - }) - }) + }), + ) - test("diff('git') returns uncommitted changes", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8") + it.instance( + "diff('git') returns uncommitted changes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* write(path.join(test.directory, "file.txt"), "original\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"]) + yield* write(path.join(test.directory, "file.txt"), "changed\n") - await withVcsOnly(tmp.path, async () => { - const diff = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff("git") - }), - ) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file: "file.txt", - status: "modified", - }), - ]), - ) - expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git") - }) - }) + const vcs = yield* init() + const diff = yield* vcs.diff("git") - test("diff('git') handles special filenames", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8") + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: "file.txt", + status: "modified", + }), + ]), + ) + expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git") + }), + { git: true }, + ) - await withVcsOnly(tmp.path, async () => { - const diff = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff("git") - }), - ) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file: weird, - status: "added", - }), - ]), - ) - }) - }) + it.instance( + "diff('git') handles special filenames", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* write(path.join(test.directory, weird), "hello\n") - test("diff('git') keeps batched patches aligned for type changes", async () => { - if (process.platform === "win32") return + const vcs = yield* init() + const diff = yield* vcs.diff("git") - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "a.txt"), "old\n", "utf-8") - await fs.writeFile(path.join(tmp.path, "b.txt"), "old\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add files"`.cwd(tmp.path).quiet() - await fs.unlink(path.join(tmp.path, "a.txt")) - await fs.symlink("target", path.join(tmp.path, "a.txt")) - await fs.writeFile(path.join(tmp.path, "b.txt"), "new\n", "utf-8") + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: weird, + status: "added", + }), + ]), + ) + }), + { git: true }, + ) - await withVcsOnly(tmp.path, async () => { - const diff = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff("git") - }), - ) - const a = diff.find((item) => item.file === "a.txt") - const b = diff.find((item) => item.file === "b.txt") + it.instance( + "diff('git') keeps batched patches aligned for type changes", + () => + Effect.gen(function* () { + if (process.platform === "win32") return - expect(a?.patch).toContain("deleted file mode") - expect(a?.patch).toContain("new file mode") - expect(b?.patch).toContain("+new") - }) - }) + const test = yield* TestInstance + yield* write(path.join(test.directory, "a.txt"), "old\n") + yield* write(path.join(test.directory, "b.txt"), "old\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add files"]) + yield* remove(path.join(test.directory, "a.txt")) + yield* symlink("target", path.join(test.directory, "a.txt")) + yield* write(path.join(test.directory, "b.txt"), "new\n") - test("diff('git') keeps carriage returns inside patch hunks", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8") + const vcs = yield* init() + const diff = yield* vcs.diff("git") + const a = diff.find((item) => item.file === "a.txt") + const b = diff.find((item) => item.file === "b.txt") - await withVcsOnly(tmp.path, async () => { - const diff = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff("git") - }), - ) - const file = diff.find((item) => item.file === "file.txt") + expect(a?.patch).toContain("deleted file mode") + expect(a?.patch).toContain("new file mode") + expect(b?.patch).toContain("+new") + }), + { git: true }, + ) - expect(file?.patch).toContain(" same\rdiff --git inside") - expect(file?.patch).toContain("-delete") - expect(() => parsePatch(file?.patch ?? "")).not.toThrow() - }) - }, 20_000) + it.instance( + "diff('git') keeps carriage returns inside patch hunks", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* write(path.join(test.directory, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"]) + yield* write(path.join(test.directory, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n") - test("diff('branch') returns changes against default branch", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - await $`git checkout -b feature/test`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet() + const vcs = yield* init() + const diff = yield* vcs.diff("git") + const file = diff.find((item) => item.file === "file.txt") - await withVcsOnly(tmp.path, async () => { - const diff = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff("branch") - }), - ) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file: "branch.txt", - status: "added", - }), - ]), - ) - }) - }) + expect(file?.patch).toContain(" same\rdiff --git inside") + expect(file?.patch).toContain("-delete") + expect(() => parsePatch(file?.patch ?? "")).not.toThrow() + }), + { git: true }, + 20_000, + ) + + it.instance( + "diff('branch') returns changes against default branch", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* git(test.directory, ["branch", "-M", "main"]) + yield* git(test.directory, ["checkout", "-b", "feature/test"]) + yield* write(path.join(test.directory, "branch.txt"), "hello\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "branch file"]) + + const vcs = yield* init() + const diff = yield* vcs.diff("branch") + + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: "branch.txt", + status: "added", + }), + ]), + ) + }), + { git: true }, + ) }) From abb1ee627858be10a88be1b63500e0b770fb9e55 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 20:59:51 -0400 Subject: [PATCH 14/32] docs(test): add Effect migration orchestration notes (#26963) --- packages/opencode/test/EFFECT_TEST_MIGRATION.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/EFFECT_TEST_MIGRATION.md b/packages/opencode/test/EFFECT_TEST_MIGRATION.md index 60cd332642..2c160b993d 100644 --- a/packages/opencode/test/EFFECT_TEST_MIGRATION.md +++ b/packages/opencode/test/EFFECT_TEST_MIGRATION.md @@ -200,11 +200,20 @@ Use this as a migration queue. Each checkbox should be safe for one agent or one Parallelization notes: -- The first four items are mostly independent and good for parallel agents. +- The first four items are mostly independent and good for separate worktrees. - `provider.test.ts`, `tool/edit.test.ts`, and `config.test.ts` should be split by cluster so agents do not edit the same file concurrently. - Any new fake boundary layer under `test/fake/*` should be small and independently useful. Do not add a fake just for one assertion unless it removes a real external dependency. - Do not combine assertion-helper design with file migrations. First collect repeated shapes, then add helpers in a separate pass. +Orchestration rules: + +- Prefer supervised foreground agents for implementation. Background agents are acceptable for research-only surveys, but code migrations need a returned diff, focused test output, and local commit before moving on. +- Create one worktree per claim and verify the branch/worktree path before edits. A status check should include `git status --short --branch` from the claimed worktree. +- After an agent reports completion, the coordinator must independently inspect `git status`, run the focused test, run `bun typecheck`, and review the diff before pushing. +- If an agent edits the wrong worktree, move the patch deliberately with `git diff` / `git apply`, then clean the accidental worktree before opening a PR. +- Keep dependency setup boring. Prefer reusing existing installed dependencies via worktrees or symlinks over running a fresh `bun install` in a temporary path unless the native build path is known to work. +- Do not delete worktrees with unpushed commits or uncommitted changes. Once a migration PR branch is pushed and clean, the local worktree can be removed while leaving the branch on the fork. + ## Effectified Test Rough Edges Track patterns that are technically Effect-native but still too noisy. These should become a second cleanup pass after the Promise-land migration is underway. From e5aa5161f2317f466cdb5eb2fe97b16120ddded5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 21:14:55 -0400 Subject: [PATCH 15/32] Remove effect-zod bridge (#26956) --- packages/core/src/effect-zod.ts | 370 --------- packages/core/src/schema.ts | 2 - packages/opencode/specs/effect/migration.md | 12 +- packages/opencode/specs/effect/schema.md | 84 +- .../specs/openapi-translation-cleanup.md | 2 +- packages/opencode/src/command/index.ts | 5 +- packages/opencode/src/config/model-id.ts | 10 +- packages/opencode/src/lsp/lsp.ts | 6 +- .../instance/httpapi/handlers/experimental.ts | 4 +- packages/opencode/src/session/message-v2.ts | 3 +- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/tool/json-schema.ts | 164 ++++ packages/opencode/src/tool/registry.ts | 72 +- packages/opencode/src/tool/tool.ts | 2 + packages/opencode/src/tool/webfetch.ts | 5 +- .../opencode/src/util/named-schema-error.ts | 16 +- packages/opencode/test/session/retry.test.ts | 24 +- .../__snapshots__/parameters.test.ts.snap | 24 +- .../opencode/test/tool/parameters.test.ts | 38 +- packages/opencode/test/tool/registry.test.ts | 90 ++- .../opencode/test/util/effect-zod.test.ts | 754 ------------------ 21 files changed, 425 insertions(+), 1266 deletions(-) delete mode 100644 packages/core/src/effect-zod.ts create mode 100644 packages/opencode/src/tool/json-schema.ts delete mode 100644 packages/opencode/test/util/effect-zod.test.ts diff --git a/packages/core/src/effect-zod.ts b/packages/core/src/effect-zod.ts deleted file mode 100644 index 42d89ec7d5..0000000000 --- a/packages/core/src/effect-zod.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { Effect, Option, Schema, SchemaAST } from "effect" -import z from "zod" - -/** - * Annotation key for providing a hand-crafted Zod schema that the walker - * should use instead of re-deriving from the AST. Attach it via - * `Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })`. - */ -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> -} - -/** - * Derive a Zod value from an Effect Schema (or a Schema-backed export with a - * `.zod` static) and narrow the result to `z.ZodObject` so `.shape`, - * `.omit`, `.extend`, and friends are accessible. - * - * The `zod()` walker returns `z.ZodType` because not every AST node decodes - * to an object; this helper keeps the "I started from a `Schema.Struct`" cast - * in one place instead of sprinkling `as unknown as z.ZodObject` across - * call sites. - * - * The return is intentionally loose — carrying Schema field types through the - * mapped `.omit()` / `.extend()` surface triggers brand-intersection - * explosions for branded primitives (`string & Brand<"SessionID">` extends - * `object` via the brand and gets walked into the prototype by `DeepPartial`, - * mapped-schema helpers, and zod's inference through `z.ZodType` - * wrappers also can't reconstruct `T` cleanly. Consumers that care about the - * post-`.omit()` shape should cast `c.req.valid(...)` to the expected type. - */ -export function zodObject(schema: S): z.ZodObject { - const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast) - return derived as unknown as z.ZodObject -} - -function isZodType(value: unknown): value is z.ZodTypeAny { - return typeof value === "object" && value !== null && "_zod" in value -} - -/** - * Emit a JSON Schema for a tool/route parameter schema — derives the zod form - * via the walker so Effect Schema inputs flow through the same zod-openapi - * pipeline the LLM/SDK layer already depends on. `io: "input"` mirrors what - * `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper. - */ -export function toJsonSchema(schema: S) { - return z.toJSONSchema(zod(schema), { io: "input" }) -} - -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 - // `description` annotations layer on top of an override so callers can - // reuse a shared override schema (e.g. `SessionID`) and still add a - // per-field description on the outer wrapper. - const base = override ?? bodyWithChecks(ast) - const desc = SchemaAST.resolveDescription(ast) - const ref = SchemaAST.resolveIdentifier(ast) - const described = desc ? base.describe(desc) : base - return ref ? described.meta({ ref }) : described -} - -function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny { - // 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. - // - // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)` - // on the inner Zod rather than a transform wrapper — so optional ASTs whose - // encoding resolves a default from Option.none() route through body()/opt(). - const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0) - const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) - const base = hasTransform ? encoded(ast) : body(ast) - return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base -} - -// 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< - Option.Option - >, - ) - if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`) - return Option.getOrElse(exit.value, () => value) -} - -// Flatten FilterGroups and any nested variants into a linear list of Filters. -// Well-known filters (Schema.isInt, isGreaterThan, isPattern, …) are -// translated into native Zod methods so their JSON Schema output includes -// the corresponding constraint (type: integer, exclusiveMinimum, pattern, …). -// Anything else falls back to a single .superRefine layer — runtime-only, -// emits no JSON Schema constraint. -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) - - const unhandled: SchemaAST.Filter[] = [] - const translated = filters.reduce((acc, filter) => { - const next = translateFilter(acc, filter) - if (next) return next - unhandled.push(filter) - return acc - }, out) - - if (unhandled.length === 0) return translated - - return translated.superRefine((value, ctx) => { - for (const filter of unhandled) { - 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 }) - } - }) -} - -// Translate a well-known Effect Schema filter into a native Zod method call on -// `out`. Dispatch is keyed on `filter.annotations.meta._tag`, which every -// built-in check factory (isInt, isGreaterThan, isPattern, …) attaches at -// construction time. Returns `undefined` for unrecognised filters so the -// caller can fall back to the generic .superRefine path. -function translateFilter(out: z.ZodTypeAny, filter: SchemaAST.Filter): z.ZodTypeAny | undefined { - const meta = (filter.annotations as { meta?: Record } | undefined)?.meta - if (!meta || typeof meta._tag !== "string") return undefined - switch (meta._tag) { - case "isInt": - return call(out, "int") - case "isFinite": - return call(out, "finite") - case "isGreaterThan": - return call(out, "gt", meta.exclusiveMinimum) - case "isGreaterThanOrEqualTo": - return call(out, "gte", meta.minimum) - case "isLessThan": - return call(out, "lt", meta.exclusiveMaximum) - case "isLessThanOrEqualTo": - return call(out, "lte", meta.maximum) - case "isBetween": { - const lo = meta.exclusiveMinimum ? call(out, "gt", meta.minimum) : call(out, "gte", meta.minimum) - if (!lo) return undefined - return meta.exclusiveMaximum ? call(lo, "lt", meta.maximum) : call(lo, "lte", meta.maximum) - } - case "isMultipleOf": - return call(out, "multipleOf", meta.divisor) - case "isMinLength": - return call(out, "min", meta.minLength) - case "isMaxLength": - return call(out, "max", meta.maxLength) - case "isLengthBetween": { - const lo = call(out, "min", meta.minimum) - if (!lo) return undefined - return call(lo, "max", meta.maximum) - } - case "isPattern": - return call(out, "regex", meta.regExp) - case "isStartsWith": - return call(out, "startsWith", meta.startsWith) - case "isEndsWith": - return call(out, "endsWith", meta.endsWith) - case "isIncludes": - return call(out, "includes", meta.includes) - case "isUUID": - return call(out, "uuid") - case "isULID": - return call(out, "ulid") - case "isBase64": - return call(out, "base64") - case "isBase64Url": - return call(out, "base64url") - } - return undefined -} - -// Invoke a named Zod method on `target` if it exists, otherwise return -// undefined so the caller can fall back. Using this helper instead of a -// typed cast keeps `translateFilter` free of per-case narrowing noise. -function call(target: z.ZodTypeAny, method: string, ...args: unknown[]): z.ZodTypeAny | undefined { - const fn = (target as unknown as Record z.ZodTypeAny) | undefined>)[method] - return typeof fn === "function" ? fn.apply(target, args) : undefined -} - -function issueMessage(issue: any): string | undefined { - if (typeof issue?.annotations?.message === "string") return issue.annotations.message - if (typeof issue?.message === "string") return issue.message - return undefined -} - -function body(ast: SchemaAST.AST): z.ZodTypeAny { - if (SchemaAST.isOptional(ast)) return opt(ast) - - switch (ast._tag) { - case "String": - return z.string() - case "Number": - return z.number() - case "Boolean": - return z.boolean() - case "Null": - return z.null() - case "Undefined": - return z.undefined() - case "Any": - case "Unknown": - return z.unknown() - case "Never": - return z.never() - case "Literal": - return z.literal(ast.literal) - case "Union": - return union(ast) - case "Objects": - return object(ast) - case "Arrays": - return array(ast) - case "Declaration": - return decl(ast) - default: - return fail(ast) - } -} - -function opt(ast: SchemaAST.AST): z.ZodTypeAny { - if (ast._tag !== "Union") return fail(ast) - const items = ast.types.filter((item) => item._tag !== "Undefined") - const inner = - items.length === 1 - ? walk(items[0]) - : items.length > 1 - ? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array]) - : z.undefined() - // Schema.withDecodingDefault attaches an encoding `Link` whose transformation - // decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke - // it to extract the default and emit `.default(...)` instead of `.optional()`. - const fallback = extractDefault(ast) - if (fallback !== undefined) return inner.default(fallback.value) - return inner.optional() -} - -type DecodeLink = { - readonly transformation: { - readonly decode: { - readonly run: ( - input: Option.Option, - options: SchemaAST.ParseOptions, - ) => Effect.Effect, unknown> - } - } -} - -function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined { - const encoding = (ast as { encoding?: ReadonlyArray }).encoding - if (!encoding?.length) return undefined - // Walk the chain of encoding Links in order; the first Getter that produces - // a value from Option.none wins. withDecodingDefault always puts its - // defaulting Link adjacent to the optional Union. - for (const link of encoding) { - const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {})) - if (probe._tag !== "Success") continue - if (Option.isSome(probe.value)) return { value: probe.value.value } - } - return undefined -} - -function union(ast: SchemaAST.Union): z.ZodTypeAny { - // When every member is a string literal, emit z.enum() so that - // JSON Schema produces { "enum": [...] } instead of { "anyOf": [{ "const": ... }] }. - if (ast.types.length >= 2 && ast.types.every((t) => t._tag === "Literal" && typeof t.literal === "string")) { - return z.enum(ast.types.map((t) => (t as SchemaAST.Literal).literal as string) as [string, ...string[]]) - } - - const items = ast.types.map(walk) - if (items.length === 1) return items[0] - if (items.length < 2) return fail(ast) - - const discriminator = ast.annotations?.discriminator - if (typeof discriminator === "string") { - return z.discriminatedUnion(discriminator, items as [z.ZodObject, z.ZodObject, ...z.ZodObject[]]) - } - - return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array]) -} - -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)) - } - - // 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)]))) - } - - // 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 { - // Pure variadic arrays: { elements: [], rest: [item] } - if (ast.elements.length === 0) { - if (ast.rest.length !== 1) return fail(ast) - return z.array(walk(ast.rest[0])) - } - // Fixed-length tuples: { elements: [a, b, ...], rest: [] } - // Tuples with a variadic tail (...rest) are not yet supported. - if (ast.rest.length > 0) return fail(ast) - const items = ast.elements.map(walk) - return z.tuple(items as [z.ZodTypeAny, ...Array]) -} - -function decl(ast: SchemaAST.Declaration): z.ZodTypeAny { - if (ast.typeParameters.length !== 1) return fail(ast) - return walk(ast.typeParameters[0]) -} - -function fail(ast: SchemaAST.AST): never { - const ref = SchemaAST.resolveIdentifier(ast) - throw new Error(`unsupported effect schema: ${ref ?? ast._tag}`) -} diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 2a6c02349f..5b4042c736 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -1,5 +1,4 @@ import { Option, Schema, SchemaGetter } from "effect" -import { zod, ZodOverride } from "./effect-zod" /** * Integer greater than zero. @@ -21,7 +20,6 @@ export const optionalOmitUndefined = (schema: S) => decode: SchemaGetter.passthrough({ strict: false }), encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)), }), - Schema.annotate({ [ZodOverride]: zod(schema).optional() }), ) /** diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index 01af9da6ce..13838e833d 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -57,17 +57,9 @@ Rules: - Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase - No `Layer.fresh` for normal per-directory isolation; use `InstanceState` -## Schema → Zod interop +## Schema boundaries -When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`: - -```ts -import { zod } from "@opencode-ai/core/effect-zod" - -export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union -``` - -See `Auth.ZodInfo` for the canonical example. +Use Effect Schema directly at HTTP, tool, and AI SDK boundaries. For provider-facing JSON Schema, use a boundary-specific helper such as `ToolJsonSchema.fromSchema(...)`; do not reintroduce generic Effect Schema → Zod conversion. ## InstanceState init patterns diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index 20b3e70e7b..1fc6a44783 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -1,19 +1,16 @@ # Schema migration Practical reference for migrating data types in `packages/opencode` from -Zod-first definitions to Effect Schema with Zod compatibility shims. +Zod-first definitions to Effect Schema. ## Goal Use Effect Schema as the source of truth for domain models, IDs, inputs, -outputs, and typed errors. Keep Zod available at existing HTTP, tool, and -compatibility boundaries by exposing a `.zod` static derived from the Effect -schema via `@opencode-ai/core/effect-zod`. +outputs, and typed errors. Prefer native Effect Schema, Standard Schema, and +native JSON Schema generation at HTTP, tool, and AI SDK boundaries. -The long-term driver is `specs/effect/http-api.md` — once the HTTP server -moves to `@effect/platform`, every Schema-first DTO can flow through -`HttpApi` / `HttpRouter` without a zod translation layer, and the entire -`effect-zod` walker plus every `.zod` static can be deleted. +The long-term driver is `specs/effect/http-api.md`: Schema-first DTOs should +flow through `HttpApi` / `HttpRouter` without a Zod translation layer. ## Preferred shapes @@ -26,19 +23,16 @@ export class Info extends Schema.Class("Foo.Info")({ id: FooID, name: Schema.String, enabled: Schema.Boolean, -}) { - static readonly zod = zod(Info) -} +}) {} ``` -If the class cannot reference itself cleanly during initialization, use the -two-step `withStatics` pattern: +If a schema needs local static helpers, use the two-step `withStatics` pattern: ```ts export const Info = Schema.Struct({ id: FooID, name: Schema.String, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}).pipe(withStatics((s) => ({ decode: Schema.decodeUnknownOption(s) }))) ``` ### Errors @@ -53,15 +47,13 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Foo ### IDs and branded leaf types -Keep branded/schema-backed IDs as Effect schemas and expose -`static readonly zod` for compatibility when callers still expect Zod. +Keep branded/schema-backed IDs as Effect schemas. ### Refinements -Reuse named refinements instead of re-spelling `z.number().int().positive()` -in every schema. The `effect-zod` walker translates the Effect versions into -the corresponding zod methods, so JSON Schema output (`type: integer`, -`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved. +Reuse named refinements instead of re-spelling numeric or string constraints in +every schema. Boundary JSON Schema helpers should normalize native Effect JSON +Schema output only where a provider requires it. ```ts const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) @@ -69,18 +61,15 @@ const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreate const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)) ``` -See `test/util/effect-zod.test.ts` for the full set of translated checks. - ## Compatibility rule -During migration, route validators, tool parameters, and any existing -Zod-based boundary should consume the derived `.zod` schema instead of +During migration, route validators, tool parameters, and AI SDK schemas should +consume Effect schemas directly or use a narrow boundary helper. Avoid maintaining a second hand-written Zod schema. The default should be: - Effect Schema owns the type -- `.zod` exists only as a compatibility surface - new domain models should not start Zod-first unless there is a concrete boundary-specific need @@ -89,27 +78,22 @@ The default should be: It is fine to keep a Zod-native schema temporarily when: - the type is only used at an HTTP or tool boundary and is not reused elsewhere -- the validator depends on Zod-only transforms or behavior not yet covered by `zod()` +- the validator is part of an existing public API that explicitly accepts Zod - the migration would force unrelated churn across a large call graph When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth. -## Escape hatches +## Boundary helpers -The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for -cases the pure-Schema path cannot express. Each one stays in the codebase -only as long as its upstream or local dependency requires it — inline -comments document when each can be deleted. +Use narrow helpers at concrete boundaries instead of a generic Schema → Zod bridge. -### `ZodOverride` annotation +- Tool parameters: `ToolJsonSchema.fromSchema(...)` and `ToolJsonSchema.fromTool(...)` +- Public config/TUI schemas: `packages/opencode/script/schema.ts` +- AI SDK object generation: `Schema.toStandardSchemaV1(...)` plus `Schema.toStandardJSONSchemaV1(...)` -Replaces the entire derivation with a hand-crafted zod schema. Used when: - -- the target carries external `$ref` metadata (e.g. - `config/model-id.ts` points at `https://models.dev/...`) -- the target is a zod-only schema that cannot yet be expressed as Schema - (e.g. `ConfigAgent.Info`, `Log.Level`) +Plugin tools are the main remaining intentional Zod boundary because the public +plugin API exposes `tool.schema = z` and `args: z.ZodRawShape`. ### Local `DeepMutable` in `config/config.ts` @@ -133,7 +117,7 @@ Migrate in this order: 2. Exported `Info`, `Input`, `Output`, and DTO types 3. Tagged domain errors 4. Service-local internal models -5. Route and tool boundary validators that can switch to `.zod` +5. Route and tool boundary validators that can switch to native Effect Schema helpers This keeps shared types canonical first and makes boundary updates mostly mechanical. @@ -142,21 +126,18 @@ mechanical. ### `src/config/` ✅ complete -All of `packages/opencode/src/config/` has been migrated. Files that still -import `z` do so only for local `ZodOverride` bridges or for `z.ZodType` -type annotations — the `export const ` values are all Effect -Schema at source. +All of `packages/opencode/src/config/` has been migrated. The `export const +` values are all Effect Schema at source. A file is considered "done" when: - its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.) are authored as Effect Schema -- any remaining zod is either a derived compat bridge (via `zod()` / - `zodObject()`), a `z.ZodType` type annotation, or a documented - `ZodOverride` escape hatch — never a hand-written parallel source of truth +- any remaining Zod is an explicit boundary compatibility choice, not a + hand-written parallel source of truth -Files that meet this bar but still carry a compat bridge are checked off -with an inline note describing the bridge and what unblocks its removal. +Files that meet this bar but still carry a compatibility boundary are checked +off with an inline note describing the boundary and what unblocks its removal. - [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider - [x] server, layout @@ -361,15 +342,8 @@ piecewise. - [ ] `src/util/update-schema.ts` - [ ] `src/worktree/index.ts` -### Do-not-migrate - -- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever - (it's what emits zod from Schema). Goes away only when the `.zod` - compatibility layer is no longer needed anywhere. - ## Notes -- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion. - Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type. - Keep the migration incremental. Converting the domain model first is more diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md index 255c09644f..5be155d1b8 100644 --- a/packages/opencode/specs/openapi-translation-cleanup.md +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -100,7 +100,7 @@ Verification: - Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`. - Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions. -- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. +- Add or fix OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. - Delete one path override only after generated OpenAPI is unchanged for that param. Concrete first targets: diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 54cfe4fcc5..3da260ea64 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -4,8 +4,6 @@ import { EffectBridge } from "@/effect/bridge" import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" -import z from "zod" -import { ZodOverride } from "@opencode-ai/core/effect-zod" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" @@ -35,12 +33,11 @@ export const Info = Schema.Struct({ model: Schema.optional(Schema.String), source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])), // Some command templates are lazy promises from MCP prompt resolution. - template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }), + template: Schema.Unknown, subtask: Schema.optional(Schema.Boolean), hints: Schema.Array(Schema.String), }).annotate({ identifier: "Command" }) -// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it export type Info = Omit, "template"> & { template: Promise | string } export function hints(template: string) { diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts index 6cba3ecd2a..ba763f9991 100644 --- a/packages/opencode/src/config/model-id.ts +++ b/packages/opencode/src/config/model-id.ts @@ -1,13 +1,5 @@ import { Schema } from "effect" -import z from "zod" -import { ZodOverride } from "@opencode-ai/core/effect-zod" -// The original Zod schema carried an external $ref pointing at the models.dev -// JSON schema. That external reference is not a named SDK component — it is a -// literal pointer to an outside schema — so the walker cannot re-derive it -// from AST metadata. Preserve the exact original Zod via ZodOverride. -export const ConfigModelID = Schema.String.annotate({ - [ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }), -}) +export const ConfigModelID = Schema.String export type ConfigModelID = Schema.Schema.Type diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 12ce5f5811..0249721c44 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -5,7 +5,6 @@ import * as LSPClient from "./client" import path from "path" import { pathToFileURL, fileURLToPath } from "url" import * as LSPServer from "./server" -import z from "zod" import { Config } from "@/config/config" import { Flag } from "@opencode-ai/core/flag/flag" import { Process } from "@/util/process" @@ -14,7 +13,6 @@ import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { containsPath } from "@/project/instance-context" import { NonNegativeInt } from "@opencode-ai/core/schema" -import { ZodOverride } from "@opencode-ai/core/effect-zod" const log = Log.create({ service: "lsp" }) @@ -56,9 +54,7 @@ export const Status = Schema.Struct({ id: Schema.String, name: Schema.String, root: Schema.String, - status: Schema.Literals(["connected", "error"]).annotate({ - [ZodOverride]: z.union([z.literal("connected"), z.literal("error")]), - }), + status: Schema.Literals(["connected", "error"]), }).annotate({ identifier: "LSPStatus" }) export type Status = typeof Status.Type diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 55272fc2f2..360daf54a5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -5,8 +5,8 @@ import { InstanceState } from "@/effect/instance-state" import { MCP } from "@/mcp" import { Project } from "@/project/project" import { Session } from "@/session/session" +import { ToolJsonSchema } from "@/tool/json-schema" import { ToolRegistry } from "@/tool/registry" -import * as EffectZod from "@opencode-ai/core/effect-zod" import { Worktree } from "@/worktree" import { Effect, Option } from "effect" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" @@ -84,7 +84,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper return list.map((item) => ({ id: item.id, description: item.description, - parameters: EffectZod.toJsonSchema(item.parameters), + parameters: ToolJsonSchema.fromTool(item), })) }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2d1d05e155..4dae820382 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1,6 +1,5 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID, MessageID, PartID } from "./schema" -import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { LSP } from "@/lsp/lsp" @@ -55,7 +54,7 @@ export const APIError = namedSchemaError("APIError", { responseBody: Schema.optional(Schema.String), metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), }) -export type APIError = z.infer +export type APIError = Schema.Schema.Type export const ContextOverflowError = namedSchemaError("ContextOverflowError", { message: Schema.String, responseBody: Schema.optional(Schema.String), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2de4bbd308..15246dac39 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,5 @@ import path from "path" import os from "os" -import * as EffectZod from "@opencode-ai/core/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" import * as Log from "@opencode-ai/core/util/log" @@ -21,6 +20,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { ToolRegistry } from "@/tool/registry" +import { ToolJsonSchema } from "@/tool/json-schema" import { MCP } from "../mcp" import { LSP } from "@/lsp/lsp" import { Flag } from "@opencode-ai/core/flag/flag" @@ -565,7 +565,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: input.model.providerID, agent: input.agent, })) { - const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters)) + const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item)) tools[item.id] = tool({ description: item.description, inputSchema: jsonSchema(schema), diff --git a/packages/opencode/src/tool/json-schema.ts b/packages/opencode/src/tool/json-schema.ts new file mode 100644 index 0000000000..edb43e11ca --- /dev/null +++ b/packages/opencode/src/tool/json-schema.ts @@ -0,0 +1,164 @@ +import type { JSONSchema7 } from "@ai-sdk/provider" +import { JsonSchema, Schema } from "effect" +import type * as Tool from "./tool" + +type JsonObject = Record +const cache = new WeakMap() + +export function fromSchema(schema: Schema.Top): JSONSchema7 { + const cached = cache.get(schema) + if (cached) return cached + + const document = Schema.toJsonSchemaDocument(schema, { additionalProperties: true }) + const result = normalize({ + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12, + ...document.schema, + ...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {}), + }) + const inlined = dropDefinitionsIfResolved(inlineLocalReferences(result)) + if (!isJsonSchema(inlined)) throw new Error("tool JSON Schema helper produced a non-schema value") + cache.set(schema, inlined) + return inlined +} + +export function fromTool(tool: Tool.Def): JSONSchema7 { + return tool.jsonSchema ?? fromSchema(tool.parameters as Schema.Top) +} + +function normalize(value: unknown, options: { stripNull?: boolean } = {}): unknown { + if (Array.isArray(value)) return value.map((item) => normalize(item)) + if (!isRecord(value)) return value + + const required = Array.isArray(value.required) + ? new Set(value.required.filter((item) => typeof item === "string")) + : undefined + const schema = Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + key === "properties" && isRecord(item) + ? Object.fromEntries( + Object.entries(item).map(([name, property]) => [ + name, + normalize(property, { stripNull: !required?.has(name) }), + ]), + ) + : normalize(item), + ]), + ) + + if (schema.additionalProperties === true) delete schema.additionalProperties + + if (options.stripNull && Array.isArray(schema.anyOf)) { + const withoutNull = schema.anyOf.filter((item) => !isRecord(item) || item.type !== "null") + if (withoutNull.length !== schema.anyOf.length) return normalize({ ...schema, anyOf: withoutNull }) + } + + if (Array.isArray(schema.anyOf)) { + const withoutNull = schema.anyOf + const number = withoutNull.find((item) => isRecord(item) && item.type === "number") + const nonFinite = withoutNull.filter( + (item) => isRecord(item) && Array.isArray(item.enum) && item.enum.every((entry) => isNonFiniteNumber(entry)), + ) + if (number && nonFinite.length === withoutNull.length - 1) { + const { anyOf: _, ...rest } = schema + return normalize({ ...number, ...rest }) + } + + if (isEmptyStructUnion(withoutNull)) { + const { anyOf: _, ...rest } = schema + return normalize({ type: "object", properties: {}, ...rest }) + } + + if (withoutNull.length === 1 && isRecord(withoutNull[0])) { + const { anyOf: _, ...rest } = schema + return normalize({ ...withoutNull[0], ...rest }) + } + } + + if (Array.isArray(schema.allOf) && schema.allOf.every(isRecord) && canFlattenAllOf(schema.allOf, schema)) { + const { allOf, ...rest } = schema + return normalize({ ...Object.assign({}, ...allOf), ...rest }) + } + + if (schema.type === "integer" && schema.maximum === undefined) { + return { minimum: Number.MIN_SAFE_INTEGER, ...schema, maximum: Number.MAX_SAFE_INTEGER } + } + + return schema +} + +function isRecord(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isJsonSchema(value: unknown): value is JSONSchema7 { + return typeof value === "boolean" || isRecord(value) +} + +function isNonFiniteNumber(value: unknown) { + return value === "NaN" || value === "Infinity" || value === "-Infinity" +} + +function isEmptyStructUnion(items: unknown[]) { + return ( + items.length === 2 && + items.some((item) => isRecord(item) && item.type === "object" && item.properties === undefined) && + items.some((item) => isRecord(item) && item.type === "array" && item.items === undefined) + ) +} + +function canFlattenAllOf(allOf: JsonObject[], parent: JsonObject) { + const keys = new Set(Object.keys(parent).filter((key) => key !== "allOf")) + return allOf.every((item) => + Object.keys(item).every((key) => { + if (keys.has(key)) return false + keys.add(key) + return true + }), + ) +} + +function inlineLocalReferences(value: unknown, definitions?: JsonObject, seen = new Set()): unknown { + if (Array.isArray(value)) return value.map((item) => inlineLocalReferences(item, definitions, seen)) + if (!isRecord(value)) return value + + const localDefinitions = definitions ?? (isRecord(value.$defs) ? value.$defs : undefined) + if (typeof value.$ref === "string" && localDefinitions) { + const name = value.$ref.match(/^#\/\$defs\/(.+)$/)?.[1] ?? value.$ref.match(/^#\/definitions\/(.+)$/)?.[1] + if (name && !seen.has(name)) { + const target = localDefinitions[name] + if (target) { + const { $ref: _, ...rest } = value + return inlineLocalReferences( + { ...(isRecord(target) ? target : {}), ...rest }, + localDefinitions, + new Set(seen).add(name), + ) + } + } + } + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, inlineLocalReferences(item, localDefinitions, seen)]), + ) +} + +function dropDefinitionsIfResolved(value: unknown): unknown { + if (!isRecord(value) || hasLocalReference(value)) return value + const { $defs: _, definitions: __, ...rest } = value + return rest +} + +function hasLocalReference(value: unknown): boolean { + if (Array.isArray(value)) return value.some(hasLocalReference) + if (!isRecord(value)) return false + if ( + typeof value.$ref === "string" && + (value.$ref.startsWith("#/$defs/") || value.$ref.startsWith("#/definitions/")) + ) { + return true + } + return Object.values(value).some(hasLocalReference) +} + +export * as ToolJsonSchema from "./json-schema" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 68251c342c..a7411a077b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -15,9 +15,9 @@ import { SkillTool } from "./skill" import * as Tool from "./tool" import { Config } from "@/config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" +import type { JSONSchema7, JSONSchema7Definition } from "@ai-sdk/provider" import { Schema } from "effect" import z from "zod" -import { ZodOverride } from "@opencode-ai/core/effect-zod" import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" @@ -137,17 +137,19 @@ export const layer: Layer.Layer< const custom: Tool.Def[] = [] function fromPlugin(id: string, def: ToolDefinition): Tool.Def { - // Plugin tools define their args as a raw Zod shape. Wrap the - // derived Zod object in a `Schema.declare` so it slots into the - // Schema-typed framework, and annotate with `ZodOverride` so the - // walker emits the original Zod object for LLM JSON Schema. - const zodParams = z.object(def.args) - const parameters = Schema.declare((u): u is unknown => zodParams.safeParse(u).success).annotate({ - [ZodOverride]: zodParams, - }) + // Plugin tools still expose Zod args publicly; keep that compatibility + // boxed at the registry boundary and give the LLM the original JSON Schema. + const entries = Object.entries(def.args) + const allZod = entries.every((entry) => isZodType(entry[1])) + const zodParams = allZod ? z.object(def.args) : undefined + const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries) + const parameters = zodParams + ? Schema.declare((u): u is unknown => zodParams.safeParse(u).success) + : Schema.Unknown return { id, parameters, + jsonSchema, description: def.description, execute: (args, toolCtx) => Effect.gen(function* () { @@ -323,8 +325,13 @@ export const layer: Layer.Layer< const output = { description: tool.description, parameters: tool.parameters, + jsonSchema: tool.jsonSchema, } yield* plugin.trigger("tool.definition", { toolID: tool.id }, output) + const jsonSchema = + output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema + ? output.jsonSchema + : undefined return { id: tool.id, description: [ @@ -335,6 +342,7 @@ export const layer: Layer.Layer< .filter(Boolean) .join("\n"), parameters: output.parameters, + jsonSchema, execute: tool.execute, formatValidationError: tool.formatValidationError, } @@ -376,4 +384,50 @@ export const defaultLayer = Layer.suspend(() => ), ) +function isZodType(value: unknown): value is z.ZodType { + return typeof value === "object" && value !== null && "_zod" in value +} + +function isJsonSchemaDefinition(value: unknown): value is JSONSchema7Definition { + return typeof value === "boolean" || (typeof value === "object" && value !== null && !Array.isArray(value)) +} + +function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 { + const properties = Object.fromEntries( + entries.filter((entry): entry is [string, JSONSchema7Definition] => isJsonSchemaDefinition(entry[1])), + ) + return { + type: "object", + properties, + required: Object.keys(properties), + } +} + +function zodJsonSchema(schema: z.ZodType): JSONSchema7 { + const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input" })) + if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema") + const { $defs, ...rest } = result + return ( + $defs && isJsonSchemaObject($defs) ? { ...rest, definitions: $defs as JSONSchema7["definitions"] } : rest + ) as JSONSchema7 +} + +function normalizeZodJsonSchema(value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item)) + if (typeof value !== "object" || value === null) return value + return Object.fromEntries( + Object.entries(value) + .filter((entry) => + (entry[0] === "exclusiveMaximum" || entry[0] === "exclusiveMinimum") && typeof entry[1] === "boolean" + ? false + : true, + ) + .map(([key, item]) => [key, normalizeZodJsonSchema(item)]), + ) +} + +function isJsonSchemaObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + export * as ToolRegistry from "./registry" diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 4b9ea8774a..a26422d04c 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -1,4 +1,5 @@ import { Effect, Schema } from "effect" +import type { JSONSchema7 } from "@ai-sdk/provider" import type { MessageV2 } from "../session/message-v2" import type { Permission } from "../permission" import type { SessionID, MessageID } from "../session/schema" @@ -38,6 +39,7 @@ export interface Def< id: string description: string parameters: Parameters + jsonSchema?: JSONSchema7 execute(args: Schema.Schema.Type, ctx: Context): Effect.Effect> formatValidationError?(error: unknown): string } diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index d2561a1301..8c2be44e99 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -12,10 +12,11 @@ const MAX_TIMEOUT = 120 * 1000 // 2 minutes export const Parameters = Schema.Struct({ url: Schema.String.annotate({ description: "The URL to fetch content from" }), format: Schema.Literals(["text", "markdown", "html"]) - .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))) .annotate({ description: "The format to return the content in (text, markdown, or html). Defaults to markdown.", - }), + default: "markdown", + }) + .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))), timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }), }) diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts index d87e1dcdb5..a5ff0828ea 100644 --- a/packages/opencode/src/util/named-schema-error.ts +++ b/packages/opencode/src/util/named-schema-error.ts @@ -1,6 +1,4 @@ import { Schema } from "effect" -import z from "zod" -import { zod } from "@opencode-ai/core/effect-zod" /** * Create a Schema-backed NamedError-shaped class. @@ -11,22 +9,14 @@ import { zod } from "@opencode-ai/core/effect-zod" * OpenAPI/SDK output is byte-identical to the original NamedError schema. * * Preserves the existing surface: - * - static `Schema` (Zod schema of the wire shape) + * - static `Schema` (Effect schema of the wire shape) * - static `isInstance(x)` * - instance `toObject()` returning `{ name, data }` * - `new X({ ...data }, { cause })` */ export function namedSchemaError(tag: Tag, fields: Fields) { - // Wire shape matches the original NamedError output so the SDK stays stable. const dataSchema = Schema.Struct(fields) - const wire = z - .object({ - name: z.literal(tag), - data: zod(dataSchema), - }) - .meta({ ref: tag }) - - // Effect Schema for the wire shape — used by HttpApi OpenAPI generation. + // Wire shape matches the original NamedError output so the SDK stays stable. const effectSchema = Schema.Struct({ name: Schema.Literal(tag), data: dataSchema, @@ -35,7 +25,7 @@ export function namedSchemaError class NamedSchemaError extends Error { - static readonly Schema = wire + static readonly Schema = effectSchema static readonly EffectSchema = effectSchema static readonly tag = tag public static isInstance(input: unknown): input is NamedSchemaError { diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 9da45c9112..22ff6cde81 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" -import { Effect, Layer, Schedule } from "effect" +import { Effect, Layer, Schedule, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -17,7 +17,7 @@ const retryProvider = "test" const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer)) function apiError(headers?: Record): MessageV2.APIError { - return MessageV2.APIError.Schema.parse( + return Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "boom", isRetryable: true, @@ -94,7 +94,7 @@ describe("session.retry.delay", () => { const step = yield* Schedule.toStepWithMetadata( SessionRetry.policy({ provider: "test", - parse: (err) => MessageV2.APIError.Schema.parse(err), + parse: Schema.decodeUnknownSync(MessageV2.APIError.Schema), set: (info) => status.set(sessionID, { type: "retry", @@ -173,7 +173,7 @@ describe("session.retry.retryable", () => { }) test("retries 500 errors even when isRetryable is false", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Internal server error", isRetryable: false, @@ -186,7 +186,7 @@ describe("session.retry.retryable", () => { }) test("retries 502 bad gateway errors", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Bad gateway", isRetryable: false, @@ -198,7 +198,7 @@ describe("session.retry.retryable", () => { }) test("retries 503 service unavailable errors", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Service unavailable", isRetryable: false, @@ -210,7 +210,7 @@ describe("session.retry.retryable", () => { }) test("does not retry 4xx errors when isRetryable is false", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Bad request", isRetryable: false, @@ -222,7 +222,7 @@ describe("session.retry.retryable", () => { }) test("retries ZlibError decompression failures", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Response decompression failed", isRetryable: true, @@ -236,7 +236,7 @@ describe("session.retry.retryable", () => { }) test("maps free limits to Go upsell action", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Free usage exceeded", isRetryable: true, @@ -262,7 +262,7 @@ describe("session.retry.retryable", () => { }) test("maps Go subscription limits to workspace PAYG upsell", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Subscription quota exceeded. You can continue using free models.", isRetryable: true, @@ -300,7 +300,7 @@ describe("session.retry.retryable", () => { }) test("maps Go subscription limits without limit metadata", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Subscription quota exceeded. You can continue using free models.", isRetryable: true, @@ -366,7 +366,7 @@ describe("session.message-v2.fromError", () => { ) test("ECONNRESET socket error is retryable", () => { - const error = MessageV2.APIError.Schema.parse( + const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)( new MessageV2.APIError({ message: "Connection reset by server", isRetryable: true, diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 601f07cb3a..d6c1bc45d8 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -45,6 +45,7 @@ Output: Creates directory 'foo'" "description": "Optional timeout in milliseconds", "exclusiveMinimum": 0, "maximum": 9007199254740991, + "minimum": -9007199254740991, "type": "integer", }, "workdir": { @@ -240,7 +241,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = ` "type": "string", }, }, - "ref": "QuestionOption", "required": [ "label", "description", @@ -254,7 +254,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = ` "type": "string", }, }, - "ref": "QuestionPrompt", "required": [ "question", "header", @@ -393,14 +392,21 @@ exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = ` "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "format": { - "default": "markdown", - "description": "The format to return the content in (text, markdown, or html). Defaults to markdown.", - "enum": [ - "text", - "markdown", - "html", + "anyOf": [ + { + "default": "markdown", + "description": "The format to return the content in (text, markdown, or html). Defaults to markdown.", + "enum": [ + "text", + "markdown", + "html", + ], + "type": "string", + }, + { + "type": "null", + }, ], - "type": "string", }, "timeout": { "description": "Optional timeout in seconds (max 120)", diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 17af7b983e..8b2dc9a74d 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -1,13 +1,13 @@ import { describe, expect, test } from "bun:test" import { Result, Schema } from "effect" -import { toJsonSchema } from "@opencode-ai/core/effect-zod" +import { ToolJsonSchema } from "../../src/tool/json-schema" // Each tool exports its parameters schema at module scope so this test can // import them without running the tool's Effect-based init. The JSON Schema // snapshot captures what the LLM sees; the parse assertions pin down the -// accepts/rejects contract. `toJsonSchema` is the same helper `session/ +// accepts/rejects contract. `ToolJsonSchema.fromSchema` is the same helper `session/ // prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay -// byte-identical regardless of whether a tool has migrated from zod to Schema. +// provider-compatible while tools use Effect Schema internally. import { Parameters as ApplyPatch } from "../../src/tool/apply_patch" import { Parameters as Edit } from "../../src/tool/edit" @@ -32,6 +32,8 @@ const parse = >(schema: S, input: unknown): S[ const accepts = (schema: Schema.Decoder, input: unknown): boolean => Result.isSuccess(Schema.decodeUnknownResult(schema)(input)) +const toJsonSchema = ToolJsonSchema.fromSchema + describe("tool parameters", () => { describe("JSON Schema (wire shape)", () => { test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot()) @@ -50,6 +52,36 @@ describe("tool parameters", () => { test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot()) test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot()) test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot()) + + test("inlines named child schemas for provider compatibility", () => { + const schema = toJsonSchema(Question) + expect(schema).not.toHaveProperty("$defs") + expect(schema).toMatchObject({ + properties: { + questions: { items: { properties: { options: { items: { properties: { label: { type: "string" } } } } } } }, + }, + }) + }) + + test("preserves required nullable fields", () => { + expect(toJsonSchema(Schema.Struct({ value: Schema.NullOr(Schema.String) }))).toMatchObject({ + properties: { value: { anyOf: expect.arrayContaining([{ type: "null" }]) } }, + }) + }) + + test("keeps repeated allOf constraints instead of dropping duplicates", () => { + expect( + toJsonSchema( + Schema.Struct({ value: Schema.String.check(Schema.isPattern(/^a/)).check(Schema.isPattern(/z$/)) }), + ), + ).toMatchObject({ properties: { value: { allOf: [{ pattern: "^a" }, { pattern: "z$" }] } } }) + }) + + test("bounds bare integer fields to safe integer range", () => { + expect(toJsonSchema(Schema.Struct({ value: Schema.Int }))).toMatchObject({ + properties: { value: { minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER } }, + }) + }) }) describe("apply_patch", () => { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index dc66c308ac..37cb7a43d8 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" -import { Effect, Layer } from "effect" +import { pathToFileURL } from "url" +import { Effect, Layer, Result, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" import { Flag } from "@opencode-ai/core/flag/flag" @@ -26,6 +27,8 @@ import { Ripgrep } from "@/file/ripgrep" import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" import { Reference } from "@/reference/reference" +import { ProviderID, ModelID } from "@/provider/schema" +import { ToolJsonSchema } from "@/tool/json-schema" const node = CrossSpawnSpawner.defaultLayer const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT @@ -55,7 +58,7 @@ const registryLayer = ToolRegistry.layer.pipe( Layer.provide(Truncate.defaultLayer), ) -const it = testEffect(Layer.mergeAll(registryLayer, node)) +const it = testEffect(Layer.mergeAll(registryLayer, node, Agent.defaultLayer)) afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout @@ -141,6 +144,89 @@ describe("tool.registry", () => { }), ) + it.instance("loads Zod-schema custom tools with JSON Schema and validation", () => + Effect.gen(function* () { + const test = yield* TestInstance + const customTools = path.join(test.directory, ".opencode", "tools") + const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href + yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(customTools, "sql.ts"), + [ + `import { tool } from ${JSON.stringify(pluginTool)}`, + "export default tool({", + " description: 'query database',", + " args: { query: tool.schema.string().describe('SQL query to execute') },", + " execute: async ({ query }) => query,", + "})", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "sql") + if (!loaded) throw new Error("custom sql tool was not loaded") + expect(loaded?.jsonSchema).toMatchObject({ + type: "object", + properties: { + query: { type: "string", description: "SQL query to execute" }, + }, + required: ["query"], + }) + expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ query: "select 1" }))).toBe(true) + expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false) + + const agents = yield* Agent.Service + const promptTools = yield* registry.tools({ + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + agent: yield* agents.get(yield* agents.defaultAgent()), + }) + const promptTool = promptTools.find((tool) => tool.id === "sql") + if (!promptTool) throw new Error("custom sql tool was not returned for prompts") + expect(ToolJsonSchema.fromTool(promptTool)).toMatchObject({ + properties: { + query: { type: "string", description: "SQL query to execute" }, + }, + required: ["query"], + }) + }), + ) + + it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () => + Effect.gen(function* () { + const test = yield* TestInstance + const tools = path.join(test.directory, ".opencode", "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "legacy.ts"), + [ + "export default {", + " description: 'legacy schema tool',", + " args: { text: { type: 'string', description: 'Text to render' } },", + " execute: async ({ text }) => text,", + "}", + "", + ].join("\n"), + ), + ) + + const registry = yield* ToolRegistry.Service + const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy") + if (!loaded) throw new Error("legacy custom tool was not loaded") + expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ + type: "object", + properties: { + text: { type: "string", description: "Text to render" }, + }, + required: ["text"], + }) + }), + ) + it.instance("loads tools with external dependencies without crashing", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts deleted file mode 100644 index ab3923d8e0..0000000000 --- a/packages/opencode/test/util/effect-zod.test.ts +++ /dev/null @@ -1,754 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Effect, Schema, SchemaGetter } from "effect" -import z from "zod" - -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" - -function json(schema: z.ZodTypeAny) { - const { $schema: _, ...rest } = z.toJSONSchema(schema) - return rest -} - -describe("util.effect-zod", () => { - test("converts class schemas for route dto shapes", () => { - class Method extends Schema.Class("ProviderAuthMethod")({ - type: Schema.Union([Schema.Literal("oauth"), Schema.Literal("api")]), - label: Schema.String, - }) {} - - const out = zod(Method) - - expect(out.meta()?.ref).toBe("ProviderAuthMethod") - expect( - out.parse({ - type: "oauth", - label: "OAuth", - }), - ).toEqual({ - type: "oauth", - label: "OAuth", - }) - }) - - test("converts structs with optional fields, arrays, and records", () => { - const out = zod( - Schema.Struct({ - foo: Schema.optional(Schema.String), - bar: Schema.Array(Schema.Number), - baz: Schema.Record(Schema.String, Schema.Boolean), - }), - ) - - expect( - out.parse({ - bar: [1, 2], - baz: { ok: true }, - }), - ).toEqual({ - bar: [1, 2], - baz: { ok: true }, - }) - expect( - out.parse({ - foo: "hi", - bar: [1], - baz: { ok: false }, - }), - ).toEqual({ - foo: "hi", - bar: [1], - baz: { ok: false }, - }) - }) - - describe("Tuples", () => { - test("fixed-length tuple parses matching array", () => { - const out = zod(Schema.Tuple([Schema.String, Schema.Number])) - expect(out.parse(["a", 1])).toEqual(["a", 1]) - expect(out.safeParse(["a"]).success).toBe(false) - expect(out.safeParse(["a", "b"]).success).toBe(false) - }) - - test("single-element tuple parses a one-element array", () => { - const out = zod(Schema.Tuple([Schema.Boolean])) - expect(out.parse([true])).toEqual([true]) - expect(out.safeParse([true, false]).success).toBe(false) - }) - - test("tuple inside a union picks the right branch", () => { - const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])])) - expect(out.parse("hello")).toBe("hello") - expect(out.parse(["foo", 42])).toEqual(["foo", 42]) - expect(out.safeParse(["foo"]).success).toBe(false) - }) - - test("plain arrays still work (no element positions)", () => { - const out = zod(Schema.Array(Schema.String)) - expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"]) - expect(out.parse([])).toEqual([]) - }) - }) - - test("string literal unions produce z.enum with enum in JSON Schema", () => { - const Action = Schema.Literals(["allow", "deny", "ask"]) - const out = zod(Action) - - expect(out.parse("allow")).toBe("allow") - expect(out.parse("deny")).toBe("deny") - expect(() => out.parse("nope")).toThrow() - - // Matches native z.enum JSON Schema output - const bridged = json(out) - const native = json(z.enum(["allow", "deny", "ask"])) - expect(bridged).toEqual(native) - expect(bridged.enum).toEqual(["allow", "deny", "ask"]) - }) - - test("ZodOverride annotation provides the Zod schema for branded IDs", () => { - const override = z.string().startsWith("per") - const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("TestID")) - - const Parent = Schema.Struct({ id: ID, name: Schema.String }) - const out = zod(Parent) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((out as any).parse({ id: "per_abc", name: "test" })).toEqual({ id: "per_abc", name: "test" }) - - const schema = json(out) as any - expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" }) - }) - - test("Schema.Class nested in a parent preserves ref via identifier", () => { - class Inner extends Schema.Class("MyInner")({ - value: Schema.String, - }) {} - - class Outer extends Schema.Class("MyOuter")({ - inner: Inner, - }) {} - - const out = zod(Outer) - expect(out.meta()?.ref).toBe("MyOuter") - - const shape = (out as any).shape ?? (out as any)._def?.shape?.() - expect(shape.inner.meta()?.ref).toBe("MyInner") - }) - - test("Schema.Class preserves identifier and uses enum format", () => { - class Rule extends Schema.Class("PermissionRule")({ - permission: Schema.String, - pattern: Schema.String, - action: Schema.Literals(["allow", "deny", "ask"]), - }) {} - - const out = zod(Rule) - expect(out.meta()?.ref).toBe("PermissionRule") - - const schema = json(out) as any - expect(schema.properties.action).toEqual({ - type: "string", - enum: ["allow", "deny", "ask"], - }) - }) - - test("ZodOverride on ID carries pattern through Schema.Class", () => { - const ID = Schema.String.annotate({ - [ZodOverride]: z.string().startsWith("per"), - }) - - class Request extends Schema.Class("TestRequest")({ - id: ID, - name: Schema.String, - }) {} - - const schema = json(zod(Request)) as any - expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" }) - expect(schema.properties.name).toEqual({ type: "string" }) - }) - - test("Permission schemas match original Zod equivalents", () => { - const MsgID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("msg") }) - const PerID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") }) - const SesID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("ses") }) - - class Tool extends Schema.Class("PermissionTool")({ - messageID: MsgID, - callID: Schema.String, - }) {} - - class Request extends Schema.Class("PermissionRequest")({ - id: PerID, - sessionID: SesID, - permission: Schema.String, - patterns: Schema.Array(Schema.String), - metadata: Schema.Record(Schema.String, Schema.Unknown), - always: Schema.Array(Schema.String), - tool: Schema.optional(Tool), - }) {} - - const bridged = json(zod(Request)) as any - expect(bridged.properties.id).toEqual({ type: "string", pattern: "^per.*" }) - expect(bridged.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" }) - expect(bridged.properties.permission).toEqual({ type: "string" }) - expect(bridged.required?.sort()).toEqual(["id", "sessionID", "permission", "patterns", "metadata", "always"].sort()) - - // Tool field is present with the ref from Schema.Class identifier - const toolSchema = json(zod(Tool)) as any - expect(toolSchema.properties.messageID).toEqual({ type: "string", pattern: "^msg.*" }) - expect(toolSchema.properties.callID).toEqual({ type: "string" }) - }) - - test("ZodOverride survives Schema.brand", () => { - const override = z.string().startsWith("ses") - const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("SessionID")) - - // The branded schema's AST still has the override - class Parent extends Schema.Class("Parent")({ - sessionID: ID, - }) {} - - const schema = json(zod(Parent)) as any - expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" }) - }) - - describe("Schema.check translation", () => { - test("filter returning string triggers refinement with that message", () => { - const isEven = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "expected an even number")) - const schema = zod(Schema.Number.check(isEven)) - - expect(schema.parse(4)).toBe(4) - const result = schema.safeParse(3) - expect(result.success).toBe(false) - expect(result.error!.issues[0].message).toBe("expected an even number") - }) - - test("filter returning false triggers refinement with fallback message", () => { - const nonEmpty = Schema.makeFilter((s: string) => s.length > 0) - const schema = zod(Schema.String.check(nonEmpty)) - - expect(schema.parse("hi")).toBe("hi") - const result = schema.safeParse("") - expect(result.success).toBe(false) - expect(result.error!.issues[0].message).toMatch(/./) - }) - - test("filter returning undefined passes validation", () => { - const alwaysOk = Schema.makeFilter(() => undefined) - const schema = zod(Schema.Number.check(alwaysOk)) - - expect(schema.parse(42)).toBe(42) - }) - - test("annotations.message on the filter is used when filter returns false", () => { - const positive = Schema.makeFilter((n: number) => n > 0, { message: "must be positive" }) - const schema = zod(Schema.Number.check(positive)) - - const result = schema.safeParse(-1) - expect(result.success).toBe(false) - expect(result.error!.issues[0].message).toBe("must be positive") - }) - - test("cross-field check on a record flags missing key", () => { - const hasKey = Schema.makeFilter((data: Record) => - "required" in data ? undefined : "missing 'required' key", - ) - const schema = zod(Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey)) - - expect(schema.parse({ required: { enabled: true } })).toEqual({ - required: { enabled: true }, - }) - - const result = schema.safeParse({ other: { enabled: true } }) - expect(result.success).toBe(false) - 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" }) - }) - }) - - 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"])) - }) - }) - - describe("well-known refinement translation", () => { - test("Schema.isInt emits type: integer in JSON Schema", () => { - const schema = zod(Schema.Number.check(Schema.isInt())) - const native = json(z.number().int()) - expect(json(schema)).toEqual(native) - expect(schema.parse(3)).toBe(3) - expect(schema.safeParse(1.5).success).toBe(false) - }) - - test("Schema.isGreaterThan(0) emits exclusiveMinimum: 0", () => { - const schema = zod(Schema.Number.check(Schema.isGreaterThan(0))) - expect((json(schema) as any).exclusiveMinimum).toBe(0) - expect(schema.parse(1)).toBe(1) - expect(schema.safeParse(0).success).toBe(false) - expect(schema.safeParse(-1).success).toBe(false) - }) - - test("Schema.isGreaterThanOrEqualTo(0) emits minimum: 0", () => { - const schema = zod(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))) - expect((json(schema) as any).minimum).toBe(0) - expect(schema.parse(0)).toBe(0) - expect(schema.safeParse(-1).success).toBe(false) - }) - - test("Schema.isLessThan(10) emits exclusiveMaximum: 10", () => { - const schema = zod(Schema.Number.check(Schema.isLessThan(10))) - expect((json(schema) as any).exclusiveMaximum).toBe(10) - expect(schema.parse(9)).toBe(9) - expect(schema.safeParse(10).success).toBe(false) - }) - - test("Schema.isLessThanOrEqualTo(10) emits maximum: 10", () => { - const schema = zod(Schema.Number.check(Schema.isLessThanOrEqualTo(10))) - expect((json(schema) as any).maximum).toBe(10) - expect(schema.parse(10)).toBe(10) - expect(schema.safeParse(11).success).toBe(false) - }) - - test("Schema.isMultipleOf(5) emits multipleOf: 5", () => { - const schema = zod(Schema.Number.check(Schema.isMultipleOf(5))) - expect((json(schema) as any).multipleOf).toBe(5) - expect(schema.parse(10)).toBe(10) - expect(schema.safeParse(7).success).toBe(false) - }) - - test("Schema.isFinite validates at runtime", () => { - const schema = zod(Schema.Number.check(Schema.isFinite())) - expect(schema.parse(1)).toBe(1) - expect(schema.safeParse(Infinity).success).toBe(false) - expect(schema.safeParse(NaN).success).toBe(false) - }) - - test("chained isInt + isGreaterThan(0) matches z.number().int().positive()", () => { - const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))) - const native = json(z.number().int().positive()) - expect(json(schema)).toEqual(native) - expect(schema.parse(3)).toBe(3) - expect(schema.safeParse(0).success).toBe(false) - expect(schema.safeParse(1.5).success).toBe(false) - }) - - test("chained isInt + isGreaterThanOrEqualTo(0) matches z.number().int().min(0)", () => { - const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))) - const native = json(z.number().int().min(0)) - expect(json(schema)).toEqual(native) - expect(schema.parse(0)).toBe(0) - expect(schema.safeParse(-1).success).toBe(false) - }) - - test("Schema.isBetween emits both bounds", () => { - const schema = zod(Schema.Number.check(Schema.isBetween({ minimum: 1, maximum: 10 }))) - const shape = json(schema) as any - expect(shape.minimum).toBe(1) - expect(shape.maximum).toBe(10) - expect(schema.parse(5)).toBe(5) - expect(schema.safeParse(11).success).toBe(false) - expect(schema.safeParse(0).success).toBe(false) - }) - - test("Schema.isBetween with exclusive bounds emits exclusiveMinimum/Maximum", () => { - const schema = zod( - Schema.Number.check( - Schema.isBetween({ minimum: 1, maximum: 10, exclusiveMinimum: true, exclusiveMaximum: true }), - ), - ) - const shape = json(schema) as any - expect(shape.exclusiveMinimum).toBe(1) - expect(shape.exclusiveMaximum).toBe(10) - expect(schema.parse(5)).toBe(5) - expect(schema.safeParse(1).success).toBe(false) - expect(schema.safeParse(10).success).toBe(false) - }) - - test("Schema.isInt32 (FilterGroup) produces integer bounds", () => { - const schema = zod(Schema.Number.check(Schema.isInt32())) - const shape = json(schema) as any - expect(shape.type).toBe("integer") - expect(shape.minimum).toBe(-2147483648) - expect(shape.maximum).toBe(2147483647) - expect(schema.parse(42)).toBe(42) - expect(schema.safeParse(1.5).success).toBe(false) - expect(schema.safeParse(2147483648).success).toBe(false) - }) - - test("Schema.isMinLength on string emits minLength", () => { - const schema = zod(Schema.String.check(Schema.isMinLength(3))) - expect((json(schema) as any).minLength).toBe(3) - expect(schema.parse("abc")).toBe("abc") - expect(schema.safeParse("ab").success).toBe(false) - }) - - test("Schema.isMaxLength on string emits maxLength", () => { - const schema = zod(Schema.String.check(Schema.isMaxLength(5))) - expect((json(schema) as any).maxLength).toBe(5) - expect(schema.parse("abcde")).toBe("abcde") - expect(schema.safeParse("abcdef").success).toBe(false) - }) - - test("Schema.isLengthBetween on string emits both bounds", () => { - const schema = zod(Schema.String.check(Schema.isLengthBetween(2, 4))) - const shape = json(schema) as any - expect(shape.minLength).toBe(2) - expect(shape.maxLength).toBe(4) - expect(schema.parse("abc")).toBe("abc") - expect(schema.safeParse("a").success).toBe(false) - expect(schema.safeParse("abcde").success).toBe(false) - }) - - test("Schema.isMinLength on array emits minItems", () => { - const schema = zod(Schema.Array(Schema.String).check(Schema.isMinLength(1))) - expect((json(schema) as any).minItems).toBe(1) - expect(schema.parse(["x"])).toEqual(["x"]) - expect(schema.safeParse([]).success).toBe(false) - }) - - test("Schema.isPattern emits pattern", () => { - const schema = zod(Schema.String.check(Schema.isPattern(/^per/))) - expect((json(schema) as any).pattern).toBe("^per") - expect(schema.parse("per_abc")).toBe("per_abc") - expect(schema.safeParse("abc").success).toBe(false) - }) - - test("Schema.isStartsWith matches native zod .startsWith() JSON Schema", () => { - const schema = zod(Schema.String.check(Schema.isStartsWith("per"))) - const native = json(z.string().startsWith("per")) - expect(json(schema)).toEqual(native) - expect(schema.parse("per_abc")).toBe("per_abc") - expect(schema.safeParse("abc").success).toBe(false) - }) - - test("Schema.isEndsWith matches native zod .endsWith() JSON Schema", () => { - const schema = zod(Schema.String.check(Schema.isEndsWith(".json"))) - const native = json(z.string().endsWith(".json")) - expect(json(schema)).toEqual(native) - expect(schema.parse("a.json")).toBe("a.json") - expect(schema.safeParse("a.txt").success).toBe(false) - }) - - test("Schema.isUUID emits format: uuid", () => { - const schema = zod(Schema.String.check(Schema.isUUID())) - expect((json(schema) as any).format).toBe("uuid") - }) - - test("mix of well-known and anonymous filters translates known and reroutes unknown to superRefine", () => { - // isInt is well-known (translates to .int()); the anonymous filter falls - // back to superRefine. - const notSeven = Schema.makeFilter((n: number) => (n !== 7 ? undefined : "no sevens allowed")) - const schema = zod(Schema.Number.check(Schema.isInt()).check(notSeven)) - - const shape = json(schema) as any - // Well-known translation is preserved — type is integer, not plain number - expect(shape.type).toBe("integer") - - // Runtime: both constraints fire - expect(schema.parse(3)).toBe(3) - expect(schema.safeParse(1.5).success).toBe(false) - const seven = schema.safeParse(7) - expect(seven.success).toBe(false) - expect(seven.error!.issues[0].message).toBe("no sevens allowed") - }) - - test("inside a struct field, well-known refinements propagate through", () => { - // Mirrors config.ts port: z.number().int().positive().optional() - const Port = Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))) - const schema = zod(Schema.Struct({ port: Port })) - const shape = json(schema) as any - expect(shape.properties.port.type).toBe("integer") - expect(shape.properties.port.exclusiveMinimum).toBe(0) - }) - }) - - describe("Schema.optionalWith defaults", () => { - test("parsing undefined returns the default value", () => { - const schema = zod( - Schema.Struct({ - mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), - }), - ) - expect(schema.parse({})).toEqual({ mode: "ctrl-x" }) - expect(schema.parse({ mode: undefined })).toEqual({ mode: "ctrl-x" }) - }) - - test("parsing a real value returns that value (default does not fire)", () => { - const schema = zod( - Schema.Struct({ - mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), - }), - ) - expect(schema.parse({ mode: "ctrl-y" })).toEqual({ mode: "ctrl-y" }) - }) - - test("default on a number field", () => { - const schema = zod( - Schema.Struct({ - count: Schema.Number.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(42))), - }), - ) - expect(schema.parse({})).toEqual({ count: 42 }) - expect(schema.parse({ count: 7 })).toEqual({ count: 7 }) - }) - - test("multiple defaulted fields inside a struct", () => { - const schema = zod( - Schema.Struct({ - leader: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), - quit: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-c"))), - inner: Schema.String, - }), - ) - expect(schema.parse({ inner: "hi" })).toEqual({ - leader: "ctrl-x", - quit: "ctrl-c", - inner: "hi", - }) - expect(schema.parse({ leader: "a", quit: "b", inner: "c" })).toEqual({ - leader: "a", - quit: "b", - inner: "c", - }) - }) - - test("JSON Schema output includes the default key", () => { - const schema = zod( - Schema.Struct({ - mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), - }), - ) - const shape = json(schema) as any - expect(shape.properties.mode.default).toBe("ctrl-x") - }) - - test("default referencing a computed value resolves when evaluated", () => { - // Simulates `keybinds.ts` style of per-platform defaults: the default is - // produced by an Effect that computes a value at decode time. - const platform = "darwin" - const fallback = platform === "darwin" ? "cmd-k" : "ctrl-k" - const schema = zod( - Schema.Struct({ - command_palette: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.sync(() => fallback))), - }), - ) - expect(schema.parse({})).toEqual({ command_palette: "cmd-k" }) - const shape = json(schema) as any - expect(shape.properties.command_palette.default).toBe("cmd-k") - }) - - test("plain Schema.optional (no default) still emits .optional() (regression)", () => { - const schema = zod(Schema.Struct({ foo: Schema.optional(Schema.String) })) - expect(schema.parse({})).toEqual({}) - expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" }) - }) - }) -}) From 8030a6c1873d73491601c08616fdf87bc3159cc8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 21:31:48 -0400 Subject: [PATCH 16/32] Emit LLM stream lifecycle events (#26971) --- .../llm/src/protocols/anthropic-messages.ts | 102 ++++++++++++------ .../llm/src/protocols/bedrock-converse.ts | 92 ++++++++++++---- packages/llm/src/protocols/gemini.ts | 26 +++-- packages/llm/src/protocols/openai-chat.ts | 18 +++- .../llm/src/protocols/openai-responses.ts | 78 +++++++++----- packages/llm/src/protocols/utils/lifecycle.ts | 88 +++++++++++++++ .../llm/src/protocols/utils/tool-stream.ts | 62 ++++++++--- packages/llm/src/tool-runtime.ts | 4 +- .../test/provider/anthropic-messages.test.ts | 40 +++++-- packages/llm/test/provider/gemini.test.ts | 84 ++++++++++----- .../llm/test/provider/openai-chat.test.ts | 56 ++++++---- .../test/provider/openai-responses.test.ts | 78 +++++++++----- packages/llm/test/tool-runtime.test.ts | 9 +- packages/llm/test/tool-stream.test.ts | 19 +++- 14 files changed, 560 insertions(+), 196 deletions(-) create mode 100644 packages/llm/src/protocols/utils/lifecycle.ts diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index d893888fd2..e27af18426 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -17,6 +17,7 @@ import { } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import * as Cache from "./utils/cache" +import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "anthropic-messages" @@ -190,6 +191,7 @@ type AnthropicEvent = Schema.Schema.Type interface ParserState { readonly tools: ToolStream.State readonly usage?: Usage + readonly lifecycle: Lifecycle.State } const invalid = ProviderShared.invalidRequest @@ -500,37 +502,45 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes if (!block) return [state, NO_EVENTS] if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) return [ { ...state, + lifecycle, tools: ToolStream.start(state.tools, event.index, { id: block.id ?? String(event.index), name: block.name ?? "", providerExecuted: block.type === "server_tool_use", }), }, - NO_EVENTS, + [...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })], ] } if (block.type === "text" && block.text) { - return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: block.text })]] + const events: LLMEvent[] = [] + return [ + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) }, + events, + ] } if (block.type === "thinking" && block.thinking) { + const events: LLMEvent[] = [] return [ - state, - [ - LLMEvent.reasoningDelta({ - id: `reasoning-${event.index ?? 0}`, - text: block.thinking, - }), - ], + { + ...state, + lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking), + }, + events, ] } const result = serverToolResultEvent(block) - return [state, result ? [result] : NO_EVENTS] + if (!result) return [state, NO_EVENTS] + const events: LLMEvent[] = [] + return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]] } const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* ( @@ -540,25 +550,37 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f const delta = event.delta if (delta?.type === "text_delta" && delta.text) { - return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: delta.text })]] satisfies StepResult + const events: LLMEvent[] = [] + return [ + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, delta.text) }, + events, + ] satisfies StepResult } if (delta?.type === "thinking_delta" && delta.thinking) { + const events: LLMEvent[] = [] return [ - state, - [LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })], + { + ...state, + lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, delta.thinking), + }, + events, ] satisfies StepResult } if (delta?.type === "signature_delta" && delta.signature) { + const events: LLMEvent[] = [] return [ - state, - [ - LLMEvent.reasoningEnd({ - id: `reasoning-${event.index ?? 0}`, - providerMetadata: anthropicMetadata({ signature: delta.signature }), - }), - ], + { + ...state, + lifecycle: Lifecycle.reasoningEnd( + state.lifecycle, + events, + `reasoning-${event.index ?? 0}`, + anthropicMetadata({ signature: delta.signature }), + ), + }, + events, ] satisfies StepResult } @@ -572,7 +594,10 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f "Anthropic Messages tool argument delta is missing its tool call", ) if (ToolStream.isError(result)) return yield* result - return [{ ...state, tools: result.tools }, result.event ? [result.event] : NO_EVENTS] satisfies StepResult + const events: LLMEvent[] = [] + const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...result.events) + return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult } return [state, NO_EVENTS] satisfies StepResult @@ -584,23 +609,30 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun ) { if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index) - return [{ ...state, tools: result.tools }, result.event ? [result.event] : NO_EVENTS] satisfies StepResult + const events: LLMEvent[] = [] + const resultEvents = result.events ?? [] + const lifecycle = resultEvents.length + ? Lifecycle.stepStart(state.lifecycle, events) + : Lifecycle.reasoningEnd( + Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`), + events, + `reasoning-${event.index}`, + ) + events.push(...resultEvents) + return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult }) const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => { const usage = mergeUsage(state.usage, mapUsage(event.usage)) - return [ - { ...state, usage }, - [ - LLMEvent.requestFinish({ - reason: mapFinishReason(event.delta?.stop_reason), - usage, - providerMetadata: event.delta?.stop_sequence - ? anthropicMetadata({ stopSequence: event.delta.stop_sequence }) - : undefined, - }), - ], - ] + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.finish(state.lifecycle, events, { + reason: mapFinishReason(event.delta?.stop_reason), + usage, + providerMetadata: event.delta?.stop_sequence + ? anthropicMetadata({ stopSequence: event.delta.stop_sequence }) + : undefined, + }) + return [{ ...state, lifecycle, usage }, events] } const onError = (state: ParserState, event: AnthropicEvent): StepResult => [ @@ -634,7 +666,7 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(AnthropicEvent), - initial: () => ({ tools: ToolStream.empty() }), + initial: () => ({ tools: ToolStream.empty(), lifecycle: Lifecycle.initial() }), step, }, }) diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index f561a6d7c5..7f5647c4a7 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -17,6 +17,7 @@ import { JsonObject, optionalArray, ProviderShared } from "./shared" import { BedrockAuth, type Credentials as BedrockCredentials } from "./utils/bedrock-auth" import { BedrockCache } from "./utils/bedrock-cache" import { BedrockMedia } from "./utils/bedrock-media" +import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "bedrock-converse" @@ -420,45 +421,64 @@ interface ParserState { // `metadata` (carries usage). Hold the terminal event in state so `onHalt` // can emit exactly one finish after both chunks have had a chance to arrive. readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined + readonly hasToolCalls: boolean + readonly lifecycle: Lifecycle.State } const step = (state: ParserState, event: BedrockEvent) => Effect.gen(function* () { if (event.contentBlockStart?.start?.toolUse) { const index = event.contentBlockStart.contentBlockIndex + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) return [ { ...state, + lifecycle, tools: ToolStream.start(state.tools, index, { id: event.contentBlockStart.start.toolUse.toolUseId, name: event.contentBlockStart.start.toolUse.name, }), }, - [], + [ + ...events, + LLMEvent.toolInputStart({ + id: event.contentBlockStart.start.toolUse.toolUseId, + name: event.contentBlockStart.start.toolUse.name, + }), + ], ] as const } if (event.contentBlockDelta?.delta?.text) { + const events: LLMEvent[] = [] return [ - state, - [ - LLMEvent.textDelta({ - id: `text-${event.contentBlockDelta.contentBlockIndex}`, - text: event.contentBlockDelta.delta.text, - }), - ], + { + ...state, + lifecycle: Lifecycle.textDelta( + state.lifecycle, + events, + `text-${event.contentBlockDelta.contentBlockIndex}`, + event.contentBlockDelta.delta.text, + ), + }, + events, ] as const } if (event.contentBlockDelta?.delta?.reasoningContent?.text) { + const events: LLMEvent[] = [] return [ - state, - [ - LLMEvent.reasoningDelta({ - id: `reasoning-${event.contentBlockDelta.contentBlockIndex}`, - text: event.contentBlockDelta.delta.reasoningContent.text, - }), - ], + { + ...state, + lifecycle: Lifecycle.reasoningDelta( + state.lifecycle, + events, + `reasoning-${event.contentBlockDelta.contentBlockIndex}`, + event.contentBlockDelta.delta.reasoningContent.text, + ), + }, + events, ] as const } @@ -472,12 +492,33 @@ const step = (state: ParserState, event: BedrockEvent) => "Bedrock Converse tool delta is missing its tool call", ) if (ToolStream.isError(result)) return yield* result - return [{ ...state, tools: result.tools }, result.event ? [result.event] : []] as const + const events: LLMEvent[] = [] + const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...result.events) + return [{ ...state, lifecycle, tools: result.tools }, events] as const } if (event.contentBlockStop) { const result = yield* ToolStream.finish(ADAPTER, state.tools, event.contentBlockStop.contentBlockIndex) - return [{ ...state, tools: result.tools }, result.event ? [result.event] : []] as const + const events: LLMEvent[] = [] + const resultEvents = result.events ?? [] + const lifecycle = resultEvents.length + ? Lifecycle.stepStart(state.lifecycle, events) + : Lifecycle.reasoningEnd( + Lifecycle.textEnd(state.lifecycle, events, `text-${event.contentBlockStop.contentBlockIndex}`), + events, + `reasoning-${event.contentBlockStop.contentBlockIndex}`, + ) + events.push(...resultEvents) + return [ + { + ...state, + hasToolCalls: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasToolCalls, + lifecycle, + tools: result.tools, + }, + events, + ] as const } if (event.messageStop) { @@ -517,7 +558,15 @@ const framing = BedrockEventStream.framing(ADAPTER) const onHalt = (state: ParserState): ReadonlyArray => state.pendingFinish - ? [LLMEvent.requestFinish({ reason: state.pendingFinish.reason, usage: state.pendingFinish.usage })] + ? (() => { + const events: LLMEvent[] = [] + Lifecycle.finish(state.lifecycle, events, { + reason: + state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason, + usage: state.pendingFinish.usage, + }) + return events + })() : [] // ============================================================================= @@ -535,7 +584,12 @@ export const protocol = Protocol.make({ }, stream: { event: BedrockEvent, - initial: () => ({ tools: ToolStream.empty(), pendingFinish: undefined }), + initial: () => ({ + tools: ToolStream.empty(), + pendingFinish: undefined, + hasToolCalls: false, + lifecycle: Lifecycle.initial(), + }), step, onHalt, }, diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index 0ee88f3beb..6e0b82abba 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -16,6 +16,7 @@ import { } from "../schema" import { JsonObject, optionalArray, ProviderShared } from "./shared" import { GeminiToolSchema } from "./utils/gemini-tool-schema" +import { Lifecycle } from "./utils/lifecycle" const ADAPTER = "gemini" export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" @@ -134,10 +135,9 @@ interface ParserState { readonly hasToolCalls: boolean readonly nextToolCallId: number readonly usage?: Usage + readonly lifecycle: Lifecycle.State } -const invalid = ProviderShared.invalidRequest - const mediaData = ProviderShared.mediaBytes // ============================================================================= @@ -324,7 +324,14 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean const finish = (state: ParserState): ReadonlyArray => state.finishReason || state.usage - ? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })] + ? (() => { + const events: LLMEvent[] = [] + Lifecycle.finish(state.lifecycle, events, { + reason: mapFinishReason(state.finishReason, state.hasToolCalls), + usage: state.usage, + }) + return events + })() : [] const step = (state: ParserState, event: GeminiEvent) => { @@ -341,21 +348,21 @@ const step = (state: ParserState, event: GeminiEvent) => { const events: LLMEvent[] = [] let hasToolCalls = nextState.hasToolCalls + let lifecycle = nextState.lifecycle let nextToolCallId = nextState.nextToolCallId for (const part of candidate.content.parts) { if ("text" in part && part.text.length > 0) { - events.push( - part.thought - ? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text }) - : LLMEvent.textDelta({ id: "text-0", text: part.text }), - ) + lifecycle = part.thought + ? Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", part.text) + : Lifecycle.textDelta(lifecycle, events, "text-0", part.text) continue } if ("functionCall" in part) { const input = part.functionCall.args const id = `tool_${nextToolCallId++}` + lifecycle = Lifecycle.stepStart(lifecycle, events) events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input })) hasToolCalls = true } @@ -365,6 +372,7 @@ const step = (state: ParserState, event: GeminiEvent) => { { ...nextState, hasToolCalls, + lifecycle, nextToolCallId, finishReason: candidate.finishReason ?? nextState.finishReason, }, @@ -388,7 +396,7 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(GeminiEvent), - initial: () => ({ hasToolCalls: false, nextToolCallId: 0 }), + initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }), step, onHalt: finish, }, diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 133adb503b..470a1473c4 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -16,6 +16,7 @@ import { } from "../schema" import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { OpenAIOptions } from "./utils/openai-options" +import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "openai-chat" @@ -147,6 +148,7 @@ interface ParserState { readonly toolCallEvents: ReadonlyArray readonly usage?: Usage readonly finishReason?: FinishReason + readonly lifecycle: Lifecycle.State } const invalid = ProviderShared.invalidRequest @@ -321,7 +323,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) => const toolDeltas = delta?.tool_calls ?? [] let tools = state.tools - if (delta?.content) events.push(LLMEvent.textDelta({ id: "text-0", text: delta.content })) + let lifecycle = state.lifecycle + + if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) for (const tool of toolDeltas) { const result = ToolStream.appendOrStart( @@ -333,7 +337,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) => ) if (ToolStream.isError(result)) return yield* result tools = result.tools - if (result.event) events.push(result.event) + if (result.events.length) lifecycle = Lifecycle.stepStart(lifecycle, events) + events.push(...result.events) } // Finalize accumulated tool inputs eagerly when finish_reason arrives so @@ -349,15 +354,20 @@ const step = (state: ParserState, event: OpenAIChatEvent) => toolCallEvents: finished?.events ?? state.toolCallEvents, usage, finishReason, + lifecycle, }, events, ] as const }) const finishEvents = (state: ParserState): ReadonlyArray => { + const events: LLMEvent[] = [] const hasToolCalls = state.toolCallEvents.length > 0 const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason - return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])] + const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...state.toolCallEvents) + if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage }) + return events } // ============================================================================= @@ -377,7 +387,7 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(OpenAIChatEvent), - initial: () => ({ tools: ToolStream.empty(), toolCallEvents: [] }), + initial: () => ({ tools: ToolStream.empty(), toolCallEvents: [], lifecycle: Lifecycle.initial() }), step, onHalt: finishEvents, }, diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 035cc07713..e31a42cd5a 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -17,6 +17,7 @@ import { } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { OpenAIOptions } from "./utils/openai-options" +import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "openai-responses" @@ -165,6 +166,7 @@ type OpenAIResponsesEvent = Schema.Schema.Type interface ParserState { readonly tools: ToolStream.State readonly hasFunctionCall: boolean + readonly lifecycle: Lifecycle.State } const invalid = ProviderShared.invalidRequest @@ -385,23 +387,32 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { if (!event.delta) return [state, NO_EVENTS] - return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]] + const events: LLMEvent[] = [] + return [ + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) }, + events, + ] } const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { const item = event.item if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS] + const providerMetadata = openaiMetadata({ itemId: item.id }) + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) return [ { + ...state, + lifecycle, hasFunctionCall: state.hasFunctionCall, tools: ToolStream.start(state.tools, item.id, { id: item.call_id ?? item.id, name: item.name ?? "", input: item.arguments ?? "", - providerMetadata: openaiMetadata({ itemId: item.id }), + providerMetadata, }), }, - NO_EVENTS, + [...events, LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata })], ] } @@ -418,10 +429,10 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallAr "OpenAI Responses tool argument delta is missing its tool call", ) if (ToolStream.isError(result)) return yield* result - return [ - { hasFunctionCall: state.hasFunctionCall, tools: result.tools }, - result.event ? [result.event] : NO_EVENTS, - ] satisfies StepResult + const events: LLMEvent[] = [] + const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...result.events) + return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult }) const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* ( @@ -440,33 +451,46 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* item.arguments === undefined ? yield* ToolStream.finish(ADAPTER, tools, item.id) : yield* ToolStream.finishWithInput(ADAPTER, tools, item.id, item.arguments) + const events: LLMEvent[] = [] + const resultEvents = result.events ?? [] + const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle + events.push(...resultEvents) return [ - { hasFunctionCall: result.event ? true : state.hasFunctionCall, tools: result.tools }, - result.event ? [result.event] : NO_EVENTS, + { + ...state, + lifecycle, + hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall, + tools: result.tools, + }, + events, ] satisfies StepResult } - if (isHostedToolItem(item)) return [state, hostedToolEvents(item)] satisfies StepResult + if (isHostedToolItem(item)) { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.stepStart(state.lifecycle, events) + events.push(...hostedToolEvents(item)) + return [{ ...state, lifecycle }, events] satisfies StepResult + } return [state, NO_EVENTS] satisfies StepResult }) -const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ - state, - [ - LLMEvent.requestFinish({ - reason: mapFinishReason(event, state.hasFunctionCall), - usage: mapUsage(event.response?.usage), - providerMetadata: - event.response?.id || event.response?.service_tier - ? openaiMetadata({ - responseId: event.response.id, - serviceTier: event.response.service_tier, - }) - : undefined, - }), - ], -] +const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + const events: LLMEvent[] = [] + const lifecycle = Lifecycle.finish(state.lifecycle, events, { + reason: mapFinishReason(event, state.hasFunctionCall), + usage: mapUsage(event.response?.usage), + providerMetadata: + event.response?.id || event.response?.service_tier + ? openaiMetadata({ + responseId: event.response.id, + serviceTier: event.response.service_tier, + }) + : undefined, + }) + return [{ ...state, lifecycle }, events] +} const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, @@ -506,7 +530,7 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(OpenAIResponsesEvent), - initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty() }), + initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty(), lifecycle: Lifecycle.initial() }), step, terminal: (event) => TERMINAL_TYPES.has(event.type), }, diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/llm/src/protocols/utils/lifecycle.ts new file mode 100644 index 0000000000..67039b137a --- /dev/null +++ b/packages/llm/src/protocols/utils/lifecycle.ts @@ -0,0 +1,88 @@ +import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema" + +export interface State { + readonly stepStarted: boolean + readonly text: ReadonlySet + readonly reasoning: ReadonlySet +} + +export const initial = (): State => ({ stepStarted: false, text: new Set(), reasoning: new Set() }) + +export const stepStart = (state: State, events: LLMEvent[]): State => { + if (state.stepStarted) return state + events.push(LLMEvent.stepStart({ index: 0 })) + return { ...state, stepStarted: true } +} + +export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { + const stepped = stepStart(state, events) + if (stepped.text.has(id)) { + events.push(LLMEvent.textDelta({ id, text })) + return stepped + } + events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text })) + return { ...stepped, text: new Set([...stepped.text, id]) } +} + +export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { + const stepped = stepStart(state, events) + if (stepped.reasoning.has(id)) { + events.push(LLMEvent.reasoningDelta({ id, text })) + return stepped + } + events.push(LLMEvent.reasoningStart({ id }), LLMEvent.reasoningDelta({ id, text })) + return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) } +} + +export const reasoningEnd = ( + state: State, + events: LLMEvent[], + id: string, + providerMetadata?: ProviderMetadata, +): State => { + if (!state.reasoning.has(id)) return state + const stepped = stepStart(state, events) + events.push(LLMEvent.reasoningEnd({ id, providerMetadata })) + const reasoning = new Set(stepped.reasoning) + reasoning.delete(id) + return { ...stepped, reasoning } +} + +export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { + if (!state.text.has(id)) return state + const stepped = stepStart(state, events) + events.push(LLMEvent.textEnd({ id, providerMetadata })) + const text = new Set(stepped.text) + text.delete(id) + return { ...stepped, text } +} + +const closeOpenBlocks = (state: State, events: LLMEvent[]): State => { + for (const id of state.reasoning) events.push(LLMEvent.reasoningEnd({ id })) + for (const id of state.text) events.push(LLMEvent.textEnd({ id })) + return { ...state, text: new Set(), reasoning: new Set() } +} + +export const finish = ( + state: State, + events: LLMEvent[], + input: { + readonly reason: FinishReason + readonly usage?: Usage + readonly providerMetadata?: ProviderMetadata + }, +): State => { + const stepped = closeOpenBlocks(stepStart(state, events), events) + events.push( + LLMEvent.stepFinish({ + index: 0, + reason: input.reason, + usage: input.usage, + providerMetadata: input.providerMetadata, + }), + LLMEvent.requestFinish(input), + ) + return { ...stepped, stepStarted: false } +} + +export * as Lifecycle from "./lifecycle" diff --git a/packages/llm/src/protocols/utils/tool-stream.ts b/packages/llm/src/protocols/utils/tool-stream.ts index aa9c70f017..8e07a64bfe 100644 --- a/packages/llm/src/protocols/utils/tool-stream.ts +++ b/packages/llm/src/protocols/utils/tool-stream.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema" +import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema" import { eventError, parseToolInput, type ToolAccumulator } from "../shared" type StreamKey = string | number @@ -27,13 +27,13 @@ export type State = Partial> /** * Result of adding argument text to one pending tool call. It returns both the * next `tools` state and the updated `tool` because parsers often need the - * current id/name immediately. `event` is present only when new text arrived; - * metadata-only deltas update identity without emitting `tool-input-delta`. + * current id/name immediately. `events` contains lifecycle and delta events + * produced by the append; metadata-only deltas update identity without output. */ export interface AppendOutcome { readonly tools: State readonly tool: PendingTool - readonly event?: ToolInputDelta + readonly events: ReadonlyArray } /** Create empty accumulator state for one provider stream. */ @@ -49,7 +49,14 @@ const withoutTool = (tools: State, key: K): State => return next } -const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => +const inputStart = (tool: PendingTool) => + LLMEvent.toolInputStart({ + id: tool.id, + name: tool.name, + providerMetadata: tool.providerMetadata, + }) + +const inputDelta = (tool: PendingTool, text: string) => LLMEvent.toolInputDelta({ id: tool.id, name: tool.name, @@ -76,11 +83,16 @@ const appendTool = ( key: K, tool: PendingTool, text: string, -): AppendOutcome => ({ - tools: withTool(tools, key, tool), - tool, - event: text.length === 0 ? undefined : inputDelta(tool, text), -}) +): AppendOutcome => { + const events: LLMEvent[] = [] + if (!tools[key]) events.push(inputStart(tool)) + if (text.length > 0) events.push(inputDelta(tool, text)) + return { + tools: withTool(tools, key, tool), + tool, + events, + } +} export const isError = (result: AppendOutcome | LLMError): result is LLMError => result instanceof LLMError @@ -121,7 +133,8 @@ export const appendOrStart = ( providerExecuted: current?.providerExecuted, providerMetadata: current?.providerMetadata, } - if (current && delta.text.length === 0 && current.id === id && current.name === name) return { tools, tool: current } + if (current && delta.text.length === 0 && current.id === id && current.name === name) + return { tools, tool: current, events: [] } return appendTool(tools, key, tool, delta.text) } @@ -139,7 +152,7 @@ export const appendExisting = ( ): AppendOutcome | LLMError => { const current = tools[key] if (!current) return eventError(route, missingToolMessage) - if (text.length === 0) return { tools, tool: current } + if (text.length === 0) return { tools, tool: current, events: [] } return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text) } @@ -152,7 +165,13 @@ export const finish = (route: string, tools: State, key: Effect.gen(function* () { const tool = tools[key] if (!tool) return { tools } - return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool) } + return { + tools: withoutTool(tools, key), + events: [ + LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), + yield* toolCall(route, tool), + ], + } }) /** @@ -164,7 +183,13 @@ export const finishWithInput = (route: string, tools: State Effect.gen(function* () { const tool = tools[key] if (!tool) return { tools } - return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool, input) } + return { + tools: withoutTool(tools, key), + events: [ + LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), + yield* toolCall(route, tool, input), + ], + } }) /** @@ -179,7 +204,14 @@ export const finishAll = (route: string, tools: State) = ) return { tools: empty(), - events: yield* Effect.forEach(pending, (tool) => toolCall(route, tool)), + events: yield* Effect.forEach(pending, (tool) => + toolCall(route, tool).pipe( + Effect.map((call) => [ + LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), + call, + ]), + ), + ).pipe(Effect.map((events) => events.flat())), } }) diff --git a/packages/llm/src/tool-runtime.ts b/packages/llm/src/tool-runtime.ts index c6e716d45e..f464525827 100644 --- a/packages/llm/src/tool-runtime.ts +++ b/packages/llm/src/tool-runtime.ts @@ -154,8 +154,8 @@ const accumulate = (state: StepState, event: LLMEvent) => { ) return } - if (event.type === "request-finish") { - state.finishReason = event.reason + if (event.type === "step-finish" || event.type === "request-finish") { + state.finishReason = event.reason === "stop" && state.toolCalls.length > 0 ? "tool-calls" : event.reason } } diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 0df3541d58..6417f73c2b 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -146,24 +146,46 @@ describe("Anthropic Messages route", () => { tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], }), ).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + cacheReadInputTokens: undefined, + cacheWriteInputTokens: undefined, + totalTokens: 6, + providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } }, + }) expect(response.toolCalls).toEqual([ - { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, ]) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "tool-input-start", id: "call_1", name: "lookup" }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, - { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, { type: "request-finish", reason: "tool-calls", - usage: new Usage({ - inputTokens: 5, - outputTokens: 1, - nonCachedInputTokens: 5, - totalTokens: 6, - providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } }, - }), + providerMetadata: undefined, + usage, }, ]) }), diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index ea4eadc498..80c32c58b3 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -204,30 +204,37 @@ describe("Gemini route", () => { reasoningTokens: 1, totalTokens: 7, }) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 3, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 1, + totalTokens: 7, + providerMetadata: { + google: { + promptTokenCount: 5, + candidatesTokenCount: 2, + totalTokenCount: 7, + thoughtsTokenCount: 1, + cachedContentTokenCount: 1, + }, + }, + }) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "reasoning-start", id: "reasoning-0" }, { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "text-start", id: "text-0" }, { type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", id: "text-0", text: "!" }, + { type: "reasoning-end", id: "reasoning-0" }, + { type: "text-end", id: "text-0" }, + { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, { type: "request-finish", reason: "stop", - usage: new Usage({ - inputTokens: 5, - outputTokens: 3, - nonCachedInputTokens: 4, - cacheReadInputTokens: 1, - reasoningTokens: 1, - totalTokens: 7, - providerMetadata: { - google: { - promptTokenCount: 5, - candidatesTokenCount: 2, - totalTokenCount: 7, - thoughtsTokenCount: 1, - cachedContentTokenCount: 1, - }, - }, - }), + usage, }, ]) }), @@ -252,22 +259,41 @@ describe("Gemini route", () => { tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], }), ).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + cacheReadInputTokens: undefined, + reasoningTokens: undefined, + totalTokens: 6, + providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } }, + }) expect(response.toolCalls).toEqual([ - { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, + { + type: "tool-call", + id: "tool_0", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, ]) expect(response.events).toEqual([ - { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, + { type: "step-start", index: 0 }, + { + type: "tool-call", + id: "tool_0", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, { type: "request-finish", reason: "tool-calls", - usage: new Usage({ - inputTokens: 5, - outputTokens: 1, - nonCachedInputTokens: 5, - totalTokens: 6, - providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } }, - }), + usage, }, ]) }), @@ -318,8 +344,10 @@ describe("Gemini route", () => { ), ) - expect(length.events).toEqual([{ type: "request-finish", reason: "length" }]) - expect(filtered.events).toEqual([{ type: "request-finish", reason: "content-filter" }]) + expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "request-finish"]) + expect(length.events.at(-1)).toMatchObject({ type: "request-finish", reason: "length" }) + expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "request-finish"]) + expect(filtered.events.at(-1)).toMatchObject({ type: "request-finish", reason: "content-filter" }) }), ) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 9c81422639..115c58849c 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -222,31 +222,36 @@ describe("OpenAI Chat route", () => { }), ) const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 2, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 0, + totalTokens: 7, + providerMetadata: { + openai: { + prompt_tokens: 5, + completion_tokens: 2, + total_tokens: 7, + prompt_tokens_details: { cached_tokens: 1 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }) expect(response.text).toBe("Hello!") expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "text-start", id: "text-0" }, { type: "text-delta", id: "text-0", text: "Hello" }, { type: "text-delta", id: "text-0", text: "!" }, + { type: "text-end", id: "text-0" }, + { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined }, { type: "request-finish", reason: "stop", - usage: new Usage({ - inputTokens: 5, - outputTokens: 2, - nonCachedInputTokens: 4, - cacheReadInputTokens: 1, - reasoningTokens: 0, - totalTokens: 7, - providerMetadata: { - openai: { - prompt_tokens: 5, - completion_tokens: 2, - total_tokens: 7, - prompt_tokens_details: { cached_tokens: 1 }, - completion_tokens_details: { reasoning_tokens: 0 }, - }, - }, - }), + usage, }, ]) }), @@ -269,9 +274,20 @@ describe("OpenAI Chat route", () => { ).pipe(Effect.provide(fixedResponse(body))) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, - { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + { type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined }, { type: "request-finish", reason: "tool-calls", usage: undefined }, ]) }), @@ -293,6 +309,8 @@ describe("OpenAI Chat route", () => { ).pipe(Effect.provide(fixedResponse(body))) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, ]) @@ -352,7 +370,7 @@ describe("OpenAI Chat route", () => { const events = Array.from( yield* LLMClient.stream(request).pipe(Stream.take(1), Stream.runCollect, Effect.provide(fixedResponse(body))), ) - expect(events.map((event) => event.type)).toEqual(["text-delta"]) + expect(events.map((event) => event.type)).toEqual(["step-start"]) }), ) }) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index da9dbd82c2..8b4469f4ed 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -333,32 +333,43 @@ describe("OpenAI Responses route", () => { }, ) const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 2, + nonCachedInputTokens: 4, + cacheReadInputTokens: 1, + reasoningTokens: 0, + totalTokens: 7, + providerMetadata: { + openai: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + input_tokens_details: { cached_tokens: 1 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }) expect(response.text).toBe("Hello!") expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { type: "text-start", id: "msg_1" }, { type: "text-delta", id: "msg_1", text: "Hello" }, { type: "text-delta", id: "msg_1", text: "!" }, + { type: "text-end", id: "msg_1" }, + { + type: "step-finish", + index: 0, + reason: "stop", + providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, + usage, + }, { type: "request-finish", reason: "stop", providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, - usage: new Usage({ - inputTokens: 5, - outputTokens: 2, - nonCachedInputTokens: 4, - cacheReadInputTokens: 1, - reasoningTokens: 0, - totalTokens: 7, - providerMetadata: { - openai: { - input_tokens: 5, - output_tokens: 2, - total_tokens: 7, - input_tokens_details: { cached_tokens: 1 }, - output_tokens_details: { reasoning_tokens: 0 }, - }, - }, - }), + usage, }, ]) }), @@ -390,8 +401,24 @@ describe("OpenAI Responses route", () => { tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], }), ).pipe(Effect.provide(fixedResponse(body))) + const usage = new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + cacheReadInputTokens: undefined, + reasoningTokens: undefined, + totalTokens: 6, + providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } }, + }) expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { + type: "tool-input-start", + id: "call_1", + name: "lookup", + providerMetadata: { openai: { itemId: "item_1" } }, + }, { type: "tool-input-delta", id: "call_1", @@ -404,23 +431,26 @@ describe("OpenAI Responses route", () => { name: "lookup", text: ':"weather"}', }, + { + type: "tool-input-end", + id: "call_1", + name: "lookup", + providerMetadata: { openai: { itemId: "item_1" } }, + }, { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, + providerExecuted: undefined, providerMetadata: { openai: { itemId: "item_1" } }, }, + { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined }, { type: "request-finish", reason: "tool-calls", - usage: new Usage({ - inputTokens: 5, - outputTokens: 1, - nonCachedInputTokens: 5, - totalTokens: 6, - providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } }, - }), + providerMetadata: undefined, + usage, }, ]) }), diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts index 8f4221784d..040a11fb68 100644 --- a/packages/llm/test/tool-runtime.test.ts +++ b/packages/llm/test/tool-runtime.test.ts @@ -313,7 +313,14 @@ describe("LLMClient tools", () => { ), ) - expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) + expect(events.map((event) => event.type)).toEqual([ + "step-start", + "text-start", + "text-delta", + "text-end", + "step-finish", + "request-finish", + ]) expect(LLMResponse.text({ events })).toBe("Done.") }), ) diff --git a/packages/llm/test/tool-stream.test.ts b/packages/llm/test/tool-stream.test.ts index 04a0035c99..b005d2666c 100644 --- a/packages/llm/test/tool-stream.test.ts +++ b/packages/llm/test/tool-stream.test.ts @@ -21,11 +21,17 @@ describe("ToolStream", () => { if (ToolStream.isError(second)) return yield* second const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0) - expect(first.event).toEqual({ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }) - expect(second.event).toEqual({ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }) + expect(first.events).toEqual([ + { type: "tool-input-start", id: "call_1", name: "lookup" }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, + ]) + expect(second.events).toEqual([{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }]) expect(finished).toEqual({ tools: {}, - event: { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + events: [ + { type: "tool-input-end", id: "call_1", name: "lookup" }, + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + ], }) }), ) @@ -50,7 +56,10 @@ describe("ToolStream", () => { expect(finished).toEqual({ tools: {}, - event: { type: "tool-call", id: "call_1", name: "lookup", input: { query: "final" } }, + events: [ + { type: "tool-input-end", id: "call_1", name: "lookup" }, + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "final" } }, + ], }) }), ) @@ -73,7 +82,9 @@ describe("ToolStream", () => { expect(finished).toEqual({ tools: {}, events: [ + { type: "tool-input-end", id: "call_1", name: "lookup" }, { type: "tool-call", id: "call_1", name: "lookup", input: {} }, + { type: "tool-input-end", id: "call_2", name: "web_search" }, { type: "tool-call", id: "call_2", From 74aa735e6ac84078b4dd04bb1b93a611b8946885 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 21:35:28 -0400 Subject: [PATCH 17/32] fix(tui): guard prompt submit against concurrent invocation (#26972) --- .../cli/cmd/tui/component/prompt/index.tsx | 17 ++++ .../test/cli/tui/prompt-submit-race.test.ts | 98 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 packages/opencode/test/cli/tui/prompt-submit-race.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index f3217fcbab..3bbfc261b6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -989,7 +989,24 @@ export function Prompt(props: PromptProps) { } }) + let submitting = false async function submit() { + // Prevent overlapping invocations (e.g. a double-pressed Enter, or the + // input's native onSubmit racing another dispatch). Without this guard, + // a second call slips past the empty-input check before the first call + // clears `store.prompt.input`, then awaits its own `session.create` and + // ultimately reads the now-empty store — sending a phantom empty prompt + // to a freshly created session. + if (submitting) return false + submitting = true + try { + return await submitInner() + } finally { + submitting = false + } + } + + async function submitInner() { setWarpNotice(undefined) // IME: double-defer may fire before onContentChange flushes the last diff --git a/packages/opencode/test/cli/tui/prompt-submit-race.test.ts b/packages/opencode/test/cli/tui/prompt-submit-race.test.ts new file mode 100644 index 0000000000..df659a01d7 --- /dev/null +++ b/packages/opencode/test/cli/tui/prompt-submit-race.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test" + +// Regression test for the prompt submit race in +// packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx (`submit`). +// +// Before the fix, two concurrent `submit()` calls (e.g. a double-pressed +// Enter, or the input's native onSubmit racing another dispatch) each +// passed the `if (!store.prompt.input) return false` guard, each +// `await sdk.client.session.create(...)`, and each only captured +// `inputText = store.prompt.input` AFTER that await. The first invocation +// finished, sent the prompt, and cleared the store; the second invocation, +// now past its await, read the cleared store and sent an empty prompt to a +// second freshly-created session - leaving an orphaned session with the +// user's actual text and a phantom session visible to the user containing +// only an assistant reply. +// +// `submitMirror` below has the exact shape of the production `submit()` +// after the fix: an in-flight `submitting` guard wraps the original body. +// Two concurrent invocations must result in exactly one submission carrying +// the user's text, with no empty-text submission. + +type Store = { input: string } + +type SubmitResult = { sessionID: string; text: string } + +type Harness = { + store: Store + submissions: SubmitResult[] + createSession(): Promise + sendPrompt(sessionID: string, text: string): Promise +} + +function createHarness(opts: { sessionCreateDelayMs: number }): Harness { + let sessionCounter = 0 + const submissions: SubmitResult[] = [] + + return { + store: { input: "" }, + submissions, + async createSession() { + sessionCounter += 1 + const id = `ses_${sessionCounter}` + await Bun.sleep(opts.sessionCreateDelayMs) + return id + }, + async sendPrompt(sessionID, text) { + submissions.push({ sessionID, text }) + }, + } +} + +function createSubmit() { + let submitting = false + return async function submit(h: Harness) { + if (submitting) return false + submitting = true + try { + if (!h.store.input) return false + const sessionID = await h.createSession() + const inputText = h.store.input + await h.sendPrompt(sessionID, inputText) + h.store.input = "" + return true + } finally { + submitting = false + } + } +} + +describe("Prompt.submit race", () => { + test("concurrent submits must not lose the user's text", async () => { + const submit = createSubmit() + const h = createHarness({ sessionCreateDelayMs: 5 }) + h.store.input = "Hello there." + + // Two invocations back-to-back, mimicking a double-Enter. + await Promise.all([submit(h), submit(h)]) + + // Every submission that did make it through must carry the actual user + // text, and no submission may have an empty text payload. + expect(h.submissions.every((s) => s.text === "Hello there.")).toBe(true) + expect(h.submissions.some((s) => s.text === "")).toBe(false) + }) + + test("a sequential second submit after clear is a no-op, not a phantom session", async () => { + const submit = createSubmit() + const h = createHarness({ sessionCreateDelayMs: 1 }) + h.store.input = "Hello there." + + await submit(h) + // After the first submission completes, the store is cleared; a second + // Enter on an empty input must not create a phantom session. + await submit(h) + + expect(h.submissions).toHaveLength(1) + expect(h.submissions[0].text).toBe("Hello there.") + }) +}) From 9e8274d2dacf0632d91e4c41f465e125e0d74da9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 21:40:44 -0400 Subject: [PATCH 18/32] Remove internal Zod schemas (#26974) --- packages/opencode/src/file/watcher.ts | 1 - packages/opencode/src/installation/index.ts | 15 +++++---------- packages/opencode/src/patch/index.ts | 9 ++++----- packages/opencode/src/provider/transform.ts | 10 ++++++---- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 146d7b4d07..ecbf76424c 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -4,7 +4,6 @@ import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" import { readdir } from "fs/promises" import path from "path" -import z from "zod" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index be3bc47693..e8c4342768 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -4,7 +4,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { withTransientReadRetry } from "@/util/effect-http-client" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import path from "path" -import z from "zod" import { BusEvent } from "@/bus/bus-event" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" @@ -45,15 +44,11 @@ export function getReleaseType(current: string, latest: string): ReleaseType { return "patch" } -export const Info = z - .object({ - version: z.string(), - latest: z.string(), - }) - .meta({ - ref: "InstallationInfo", - }) -export type Info = z.infer +export const Info = Schema.Struct({ + version: Schema.String, + latest: Schema.String, +}).annotate({ identifier: "InstallationInfo" }) +export type Info = Schema.Schema.Type export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index fd5fff5625..3dfa6f2d06 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -1,4 +1,4 @@ -import z from "zod" +import { Schema } from "effect" import * as path from "path" import * as fs from "fs/promises" import { readFileSync } from "fs" @@ -7,12 +7,11 @@ import * as Bom from "../util/bom" const log = Log.create({ service: "patch" }) -// Schema definitions -export const PatchSchema = z.object({ - patchText: z.string().describe("The full patch text that describes all changes to be made"), +export const PatchSchema = Schema.Struct({ + patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }), }) -export type PatchParams = z.infer +export type PatchParams = Schema.Schema.Type // Core types matching the Rust implementation export interface ApplyPatchArgs { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index bd778dacc5..72ec881e7b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1,7 +1,6 @@ import type { ModelMessage, ToolResultPart } from "ai" import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" -import type { JSONSchema } from "zod/v4/core" import type * as Provider from "./provider" import type * as ModelsDev from "./models" import { iife } from "@/util/iife" @@ -1281,7 +1280,7 @@ export function maxOutputTokens(model: Provider.Model): number { return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX } -export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 { +export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 { /* if (["openai", "azure"].includes(providerID)) { if (schema.type === "object" && schema.properties) { @@ -1312,7 +1311,10 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS return result } - schema = sanitizeMoonshot(schema) as JSONSchema.BaseSchema | JSONSchema7 + const sanitized = sanitizeMoonshot(schema) + if (typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized)) { + schema = sanitized + } } // Convert integer enums to string enums for Google/Gemini @@ -1394,7 +1396,7 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS schema = sanitizeGemini(schema) } - return schema as JSONSchema7 + return schema } export * as ProviderTransform from "./transform" From 100763034792ccf0386adfa356e097d5f825f5c0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 21:41:56 -0400 Subject: [PATCH 19/32] Migrate runtime validators to Effect Schema (#26975) --- .../src/cli/cmd/tui/context/editor-zed.ts | 47 ++--- .../src/cli/cmd/tui/context/editor.ts | 163 +++++++++--------- packages/opencode/src/mcp/auth.ts | 50 +++--- .../src/plugin/github-copilot/models.ts | 77 +++++---- 4 files changed, 176 insertions(+), 161 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts index 6805f0b666..611db406b5 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts @@ -1,33 +1,36 @@ import { Database } from "bun:sqlite" import os from "node:os" import path from "node:path" -import z from "zod" +import { Option, Schema } from "effect" import { Filesystem } from "@/util/filesystem" import type { EditorSelection } from "./editor" -const ZedEditorRowSchema = z.object({ - item_kind: z.string(), - editor_id: z.number().nullable(), - workspace_id: z.number(), - workspace_paths: z.string().nullable(), - timestamp: z.string(), - buffer_path: z.string().nullable(), +const ZedEditorRowSchema = Schema.Struct({ + item_kind: Schema.String, + editor_id: Schema.NullOr(Schema.Number), + workspace_id: Schema.Number, + workspace_paths: Schema.NullOr(Schema.String), + timestamp: Schema.String, + buffer_path: Schema.NullOr(Schema.String), }) -const ZedSelectionRowSchema = z.object({ - selection_start: z.number().nullable(), - selection_end: z.number().nullable(), +const ZedSelectionRowSchema = Schema.Struct({ + selection_start: Schema.NullOr(Schema.Number), + selection_end: Schema.NullOr(Schema.Number), }) -const ZedEditorContentsSchema = z.object({ - contents: z.string().nullable(), +const ZedEditorContentsSchema = Schema.Struct({ + contents: Schema.NullOr(Schema.String), }) +const decodeZedEditorRow = Schema.decodeUnknownOption(ZedEditorRowSchema) +const decodeZedSelectionRow = Schema.decodeUnknownOption(ZedSelectionRowSchema) +const decodeZedEditorContents = Schema.decodeUnknownOption(ZedEditorContentsSchema) + const utf8 = new TextEncoder() -type ZedEditorRow = z.infer +type ZedEditorRow = Schema.Schema.Type type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number } -type ZedSelectionRow = z.infer export type ZedSelectionResult = | { type: "selection"; selection: EditorSelection } @@ -107,8 +110,8 @@ function queryZedActiveEditor(dbPath: string, cwd: string) { .all() const rows = raw.flatMap((row) => { - const parsed = ZedEditorRowSchema.safeParse(row) - return parsed.success ? [parsed.data] : [] + const parsed = decodeZedEditorRow(row) + return Option.isSome(parsed) ? [parsed.value] : [] }) if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const } @@ -143,8 +146,8 @@ function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) { .all({ $editorID: row.editor_id, $workspaceID: row.workspace_id }) const selections = raw.flatMap((selection) => { - const parsed = ZedSelectionRowSchema.safeParse(selection) - return parsed.success ? [parsed.data] : [] + const parsed = decodeZedSelectionRow(selection) + return Option.isSome(parsed) ? [parsed.value] : [] }) if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const } @@ -160,7 +163,7 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) { let db: Database | undefined try { db = new Database(dbPath, { readonly: true }) - const parsed = ZedEditorContentsSchema.safeParse( + const parsed = decodeZedEditorContents( db .query( `select contents @@ -169,8 +172,8 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) { ) .get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }), ) - if (!parsed.success) return { type: "unavailable" as const } - return { type: "contents" as const, contents: parsed.data.contents } + if (Option.isNone(parsed)) return { type: "unavailable" as const } + return { type: "contents" as const, contents: parsed.value.contents } } catch { return { type: "unavailable" as const } } finally { diff --git a/packages/opencode/src/cli/cmd/tui/context/editor.ts b/packages/opencode/src/cli/cmd/tui/context/editor.ts index 6d9e04cf84..ea7fd5810b 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor.ts @@ -3,92 +3,102 @@ import os from "node:os" import path from "node:path" import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" -import z from "zod" +import { Option, Schema, SchemaGetter } from "effect" import { isRecord } from "@/util/record" import { createSimpleContext } from "./helper" import { resolveZedDbPath, resolveZedSelection } from "./editor-zed" const MCP_PROTOCOL_VERSION = "2025-11-25" -const JsonRpcMessageSchema = z.object({ - id: z.union([z.number(), z.string(), z.null()]).optional(), - method: z.string().optional(), - params: z.unknown().optional(), - result: z.unknown().optional(), - error: z - .object({ - code: z.number().optional(), - message: z.string().optional(), - }) - .optional(), +const JsonRpcMessageSchema = Schema.Struct({ + id: Schema.optional(Schema.Union([Schema.Number, Schema.String, Schema.Null])), + method: Schema.optional(Schema.String), + params: Schema.optional(Schema.Unknown), + result: Schema.optional(Schema.Unknown), + error: Schema.optional( + Schema.Struct({ + code: Schema.optional(Schema.Number), + message: Schema.optional(Schema.String), + }), + ), }) -const PositionSchema = z.object({ - line: z.number(), - character: z.number(), +const PositionSchema = Schema.Struct({ + line: Schema.Number, + character: Schema.Number, }) -const EditorSelectionRangeSchema = z.object({ - text: z.string(), - selection: z.object({ +const EditorSelectionRangeSchema = Schema.Struct({ + text: Schema.String, + selection: Schema.Struct({ start: PositionSchema, end: PositionSchema, }), }) -const EditorSelectionSchema = z - .union([ - z.object({ - filePath: z.string(), - source: z.enum(["websocket", "zed"]).optional(), - ranges: z.array(EditorSelectionRangeSchema).min(1), - }), - z.object({ - text: z.string(), - filePath: z.string(), - source: z.enum(["websocket", "zed"]).optional(), - selection: z.object({ - start: PositionSchema, - end: PositionSchema, - }), - }), - ]) - .transform((value) => - "ranges" in value - ? value - : { - filePath: value.filePath, - source: value.source, - ranges: [ - { - text: value.text, - selection: value.selection, - }, - ], - }, - ) - -const EditorMentionSchema = z.object({ - filePath: z.string(), - lineStart: z.number(), - lineEnd: z.number(), +const EditorSelectionRangesSchema = Schema.Struct({ + filePath: Schema.String, + source: Schema.optional(Schema.Literals(["websocket", "zed"])), + ranges: Schema.mutable(Schema.Array(EditorSelectionRangeSchema).check(Schema.isMinLength(1))), }) -const EditorServerInfoSchema = z.object({ - protocolVersion: z.string().optional(), - serverInfo: z - .object({ - name: z.string().optional(), - version: z.string().optional(), - }) - .optional(), +const EditorSelectionSchema = Schema.Union([ + EditorSelectionRangesSchema, + Schema.Struct({ + text: Schema.String, + filePath: Schema.String, + source: Schema.optional(Schema.Literals(["websocket", "zed"])), + selection: Schema.Struct({ + start: PositionSchema, + end: PositionSchema, + }), + }), +]).pipe( + Schema.decodeTo(EditorSelectionRangesSchema, { + decode: SchemaGetter.transform((value) => + "ranges" in value + ? value + : { + filePath: value.filePath, + source: value.source, + ranges: [ + { + text: value.text, + selection: value.selection, + }, + ], + }, + ), + encode: SchemaGetter.passthrough({ strict: false }), + }), +) + +const EditorMentionSchema = Schema.Struct({ + filePath: Schema.String, + lineStart: Schema.Number, + lineEnd: Schema.Number, }) -type JsonRpcMessage = z.infer -export type EditorSelection = z.infer -export type EditorMention = z.infer +const EditorServerInfoSchema = Schema.Struct({ + protocolVersion: Schema.optional(Schema.String), + serverInfo: Schema.optional( + Schema.Struct({ + name: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), + }), + ), +}) + +const decodeJsonRpcMessage = Schema.decodeUnknownOption(JsonRpcMessageSchema) +const decodeEditorSelection = Schema.decodeUnknownOption(EditorSelectionSchema) +const decodeEditorMention = Schema.decodeUnknownOption(EditorMentionSchema) +const decodeEditorServerInfo = Schema.decodeUnknownOption(EditorServerInfoSchema) + +type JsonRpcMessage = Schema.Schema.Type +export type EditorSelection = Schema.Schema.Type +export type EditorMention = Schema.Schema.Type export type EditorLabelState = "pending" | "sent" | "none" -type EditorServerInfo = z.infer +type EditorServerInfo = Schema.Schema.Type type EditorConnection = { url: string @@ -214,16 +224,15 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create const message = parseMessage(event.data) if (!message) return - const selection = - message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined - if (selection?.success) { - setSelection({ ...selection.data, source: "websocket" }) + const selection = message.method === "selection_changed" ? decodeEditorSelection(message.params) : Option.none() + if (Option.isSome(selection)) { + setSelection({ ...selection.value, source: "websocket" }) return } - const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined - if (mention?.success) { - mentionListeners.forEach((listener) => listener(mention.data)) + const mention = message.method === "at_mentioned" ? decodeEditorMention(message.params) : Option.none() + if (Option.isSome(mention)) { + mentionListeners.forEach((listener) => listener(mention.value)) return } @@ -235,9 +244,9 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create pending.delete(message.id) if (message.error) return - const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined - if (initialize?.success) { - setStore("server", initialize.data) + const initialize = method === "initialize" ? decodeEditorServerInfo(message.result) : Option.none() + if (Option.isSome(initialize)) { + setStore("server", initialize.value) send({ method: "notifications/initialized" }) return } @@ -447,7 +456,7 @@ function parseMessage(value: unknown) { if (typeof value !== "string") return try { - return JsonRpcMessageSchema.parse(JSON.parse(value)) + return Option.getOrUndefined(decodeJsonRpcMessage(JSON.parse(value))) } catch { return } diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index b07d59870b..be19be0af0 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -1,33 +1,35 @@ import path from "path" -import z from "zod" import { Global } from "@opencode-ai/core/global" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Option, Schema } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" -export const Tokens = z.object({ - accessToken: z.string(), - refreshToken: z.string().optional(), - expiresAt: z.number().optional(), - scope: z.string().optional(), +export const Tokens = Schema.Struct({ + accessToken: Schema.mutableKey(Schema.String), + refreshToken: Schema.mutableKey(Schema.optional(Schema.String)), + expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), + scope: Schema.mutableKey(Schema.optional(Schema.String)), }) -export type Tokens = z.infer +export type Tokens = Schema.Schema.Type -export const ClientInfo = z.object({ - clientId: z.string(), - clientSecret: z.string().optional(), - clientIdIssuedAt: z.number().optional(), - clientSecretExpiresAt: z.number().optional(), +export const ClientInfo = Schema.Struct({ + clientId: Schema.mutableKey(Schema.String), + clientSecret: Schema.mutableKey(Schema.optional(Schema.String)), + clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)), + clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), }) -export type ClientInfo = z.infer +export type ClientInfo = Schema.Schema.Type -export const Entry = z.object({ - tokens: Tokens.optional(), - clientInfo: ClientInfo.optional(), - codeVerifier: z.string().optional(), - oauthState: z.string().optional(), - serverUrl: z.string().optional(), +export const Entry = Schema.Struct({ + tokens: Schema.mutableKey(Schema.optional(Tokens)), + clientInfo: Schema.mutableKey(Schema.optional(ClientInfo)), + codeVerifier: Schema.mutableKey(Schema.optional(Schema.String)), + oauthState: Schema.mutableKey(Schema.optional(Schema.String)), + serverUrl: Schema.mutableKey(Schema.optional(Schema.String)), }) -export type Entry = z.infer +export type Entry = Schema.Schema.Type + +const decodeAuthData = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry)) +type AuthData = Record const filepath = path.join(Global.Path.data, "mcp-auth.json") @@ -56,8 +58,8 @@ export const layer = Layer.effect( const all = Effect.fn("McpAuth.all")(function* () { return yield* fs.readJson(filepath).pipe( - Effect.map((data) => data as Record), - Effect.catch(() => Effect.succeed({} as Record)), + Effect.map((data): AuthData => Option.getOrElse(decodeAuthData(data), () => ({}) as AuthData) as AuthData), + Effect.catch(() => Effect.succeed({} as AuthData)), ) }) @@ -93,7 +95,7 @@ export const layer = Layer.effect( yield* set(mcpName, entry, serverUrl) }) - const clearField = (field: K, spanName: string) => + const clearField = (field: keyof Entry, spanName: string) => Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) { const entry = yield* get(mcpName) if (entry) { diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 8fa8dee763..a488be4a48 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -1,50 +1,51 @@ -import { z } from "zod" import type { Model } from "@opencode-ai/sdk/v2" +import { Schema } from "effect" -export const schema = z.object({ - data: z.array( - z.object({ - model_picker_enabled: z.boolean(), - id: z.string(), - name: z.string(), +export const schema = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + model_picker_enabled: Schema.Boolean, + id: Schema.String, + name: Schema.String, // every version looks like: `{model.id}-YYYY-MM-DD` - version: z.string(), - supported_endpoints: z.array(z.string()).optional(), - policy: z - .object({ - state: z.string().optional(), - }) - .optional(), - capabilities: z.object({ - family: z.string(), - limits: z.object({ - max_context_window_tokens: z.number(), - max_output_tokens: z.number(), - max_prompt_tokens: z.number(), - vision: z - .object({ - max_prompt_image_size: z.number(), - max_prompt_images: z.number(), - supported_media_types: z.array(z.string()), - }) - .optional(), + version: Schema.String, + supported_endpoints: Schema.optional(Schema.Array(Schema.String)), + policy: Schema.optional( + Schema.Struct({ + state: Schema.optional(Schema.String), }), - supports: z.object({ - adaptive_thinking: z.boolean().optional(), - max_thinking_budget: z.number().optional(), - min_thinking_budget: z.number().optional(), - reasoning_effort: z.array(z.string()).optional(), - streaming: z.boolean(), - structured_outputs: z.boolean().optional(), - tool_calls: z.boolean(), - vision: z.boolean().optional(), + ), + capabilities: Schema.Struct({ + family: Schema.String, + limits: Schema.Struct({ + max_context_window_tokens: Schema.Number, + max_output_tokens: Schema.Number, + max_prompt_tokens: Schema.Number, + vision: Schema.optional( + Schema.Struct({ + max_prompt_image_size: Schema.Number, + max_prompt_images: Schema.Number, + supported_media_types: Schema.Array(Schema.String), + }), + ), + }), + supports: Schema.Struct({ + adaptive_thinking: Schema.optional(Schema.Boolean), + max_thinking_budget: Schema.optional(Schema.Number), + min_thinking_budget: Schema.optional(Schema.Number), + reasoning_effort: Schema.optional(Schema.Array(Schema.String)), + streaming: Schema.Boolean, + structured_outputs: Schema.optional(Schema.Boolean), + tool_calls: Schema.Boolean, + vision: Schema.optional(Schema.Boolean), }), }), }), ), }) -type Item = z.infer["data"][number] +type Item = Schema.Schema.Type["data"][number] +const decodeModels = Schema.decodeUnknownSync(schema) function build(key: string, remote: Item, url: string, prev?: Model): Model { const reasoning = @@ -165,7 +166,7 @@ export async function get( if (!res.ok) { throw new Error(`Failed to fetch models: ${res.status}`) } - return schema.parse(await res.json()) + return decodeModels(await res.json()) }) const result = { ...existing } From c43d606f8e7019131add5140076ddf5ba7a271d7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 21:42:04 -0400 Subject: [PATCH 20/32] agent: use Effect schema for generated agent object (#26973) --- packages/opencode/src/agent/agent.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index d96e508c9d..b9b56396fa 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,5 +1,4 @@ import { Config } from "@/config/config" -import z from "zod" import { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { generateObject, streamObject, type ModelMessage } from "ai" @@ -49,6 +48,12 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "Agent" }) export type Info = DeepMutable> +const GeneratedAgent = Schema.Struct({ + identifier: Schema.String, + whenToUse: Schema.String, + systemPrompt: Schema.String, +}) + export interface Interface { readonly get: (agent: string) => Effect.Effect readonly list: () => Effect.Effect @@ -405,11 +410,10 @@ export const layer = Layer.effect( }, ], model: language, - schema: z.object({ - identifier: z.string(), - whenToUse: z.string(), - systemPrompt: z.string(), - }), + schema: Object.assign( + Schema.toStandardSchemaV1(GeneratedAgent), + Schema.toStandardJSONSchemaV1(GeneratedAgent), + ), } satisfies Parameters[0] if (isOpenaiOauth) { From ce720207500f66127629d1e9f7209324d8b6370f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 21:42:17 -0400 Subject: [PATCH 21/32] test(tool): migrate edit tests to Effect runner (#26977) --- packages/opencode/test/tool/edit.test.ts | 910 +++++++++-------------- 1 file changed, 356 insertions(+), 554 deletions(-) diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index a629ff07d1..572fcd9aa4 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -1,19 +1,20 @@ import { afterAll, afterEach, describe, test, expect } from "bun:test" import path from "path" import fs from "fs/promises" -import { Effect, Layer, ManagedRuntime } from "effect" +import { Cause, Deferred, Effect, Exit, Layer, ManagedRuntime } from "effect" import { EditTool } from "../../src/tool/edit" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, TestInstance, tmpdir } from "../fixture/fixture" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" -import { BusEvent } from "../../src/bus/bus-event" import { Truncate } from "@/tool/truncate" import { SessionID, MessageID } from "../../src/session/schema" +import * as Tool from "../../src/tool/tool" +import { testEffect } from "../lib/effect" +import { FileWatcher } from "../../src/file/watcher" const ctx = { sessionID: SessionID.make("ses_test-edit-session"), @@ -30,17 +31,19 @@ afterEach(async () => { await disposeAllInstances() }) -const runtime = ManagedRuntime.make( - Layer.mergeAll( - LSP.defaultLayer, - AppFileSystem.defaultLayer, - Format.defaultLayer, - Bus.layer, - Truncate.defaultLayer, - Agent.defaultLayer, - ), +const layer = Layer.mergeAll( + LSP.defaultLayer, + AppFileSystem.defaultLayer, + Format.defaultLayer, + Bus.layer, + Truncate.defaultLayer, + Agent.defaultLayer, ) +const it = testEffect(layer) + +const runtime = ManagedRuntime.make(layer) + afterAll(async () => { await runtime.dispose() }) @@ -53,464 +56,258 @@ const resolve = () => }), ) -const subscribeBus = (def: D, callback: () => unknown) => - runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback))) +const init = Effect.fn("EditToolTest.init")(function* () { + const info = yield* EditTool + return yield* info.init() +}) -async function onceBus(def: D) { - const result = Promise.withResolvers() - const unsub = await subscribeBus(def, () => { - unsub() - result.resolve() - }) - return { - wait: result.promise, - unsub, +const run = Effect.fn("EditToolTest.run")(function* ( + args: Tool.InferParameters, + next: Tool.Context = ctx, +) { + const tool = yield* init() + return yield* tool.execute(args, next) +}) + +const fail = Effect.fn("EditToolTest.fail")(function* (args: Tool.InferParameters) { + const exit = yield* run(args).pipe(Effect.exit) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + return err instanceof Error ? err : new Error(String(err)) } -} + throw new Error("expected edit to fail") +}) + +const put = Effect.fn("EditToolTest.put")(function* (p: string, content: string) { + const fs = yield* AppFileSystem.Service + yield* fs.writeWithDirs(p, content) +}) + +const load = Effect.fn("EditToolTest.load")(function* (p: string) { + const fs = yield* AppFileSystem.Service + return yield* fs.readFileString(p) +}) + +const loadRaw = Effect.fn("EditToolTest.loadRaw")(function* (p: string) { + return yield* Effect.promise(() => fs.readFile(p, "utf-8")) +}) + +const makeDirectory = Effect.fn("EditToolTest.makeDirectory")(function* (p: string) { + const fs = yield* AppFileSystem.Service + yield* fs.makeDirectory(p) +}) + +const onceBus = Effect.fn("EditToolTest.onceBus")(function* (def: typeof FileWatcher.Event.Updated) { + const bus = yield* Bus.Service + const deferred = yield* Deferred.make() + const unsub = yield* bus.subscribeCallback(def, () => Effect.runSync(Deferred.succeed(deferred, undefined))) + yield* Effect.addFinalizer(() => Effect.sync(unsub)) + return deferred +}) describe("tool.edit", () => { describe("creating new files", () => { - test("creates new file when oldString is empty", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "newfile.txt") + it.instance("creates new file when oldString is empty", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "newfile.txt") + const result = yield* run({ filePath: filepath, oldString: "", newString: "new content" }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - const result = await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "", - newString: "new content", - }, - ctx, - ), - ) + expect(result.metadata.diff).toContain("new content") + expect(yield* load(filepath)).toBe("new content") + }), + ) - expect(result.metadata.diff).toContain("new content") + it.instance("preserves BOM when oldString is empty on existing files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.cs") + const bom = String.fromCharCode(0xfeff) + yield* put(filepath, `${bom}using System;\n`) - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("new content") - }, - }) - }) + const result = yield* run({ filePath: filepath, oldString: "", newString: "using Up;\n" }) - test("preserves BOM when oldString is empty on existing files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "existing.cs") - const bom = String.fromCharCode(0xfeff) - await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8") + expect(result.metadata.diff).toContain("-using System;") + expect(result.metadata.diff).toContain("+using Up;") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - const result = await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "", - newString: "using Up;\n", - }, - ctx, - ), - ) + const content = yield* loadRaw(filepath) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), + ) - expect(result.metadata.diff).toContain("-using System;") - expect(result.metadata.diff).toContain("+using Up;") + it.instance("creates new file with nested directories", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "nested", "dir", "file.txt") - const content = await fs.readFile(filepath, "utf-8") - expect(content.charCodeAt(0)).toBe(0xfeff) - expect(content.slice(1)).toBe("using Up;\n") - }, - }) - }) + yield* run({ filePath: filepath, oldString: "", newString: "nested file" }) - test("creates new file with nested directories", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "nested", "dir", "file.txt") + expect(yield* load(filepath)).toBe("nested file") + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "", - newString: "nested file", - }, - ctx, - ), - ) + it.instance("emits add event for new files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const updated = yield* onceBus(FileWatcher.Event.Updated) - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("nested file") - }, - }) - }) - - test("emits add event for new files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "new.txt") - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const { FileWatcher } = await import("../../src/file/watcher") - - const updated = await onceBus(FileWatcher.Event.Updated) - - try { - const edit = await resolve() - await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "", - newString: "content", - }, - ctx, - ), - ) - - await updated.wait - } finally { - updated.unsub() - } - }, - }) - }) + yield* run({ filePath: path.join(test.directory, "new.txt"), oldString: "", newString: "content" }) + yield* Deferred.await(updated) + }), + ) }) describe("editing existing files", () => { - test("replaces text in existing file", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "existing.txt") - await fs.writeFile(filepath, "old content here", "utf-8") + it.instance("replaces text in existing file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.txt") + yield* put(filepath, "old content here") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - const result = await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "old content", - newString: "new content", - }, - ctx, - ), - ) + const result = yield* run({ filePath: filepath, oldString: "old content", newString: "new content" }) - expect(result.output).toContain("Edit applied successfully") + expect(result.output).toContain("Edit applied successfully") + expect(yield* load(filepath)).toBe("new content here") + }), + ) - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("new content here") - }, - }) - }) + it.instance("replaces the first visible line in BOM files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.cs") + const bom = String.fromCharCode(0xfeff) + yield* put(filepath, `${bom}using System;\nclass Test {}\n`) - test("replaces the first visible line in BOM files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "existing.cs") - const bom = String.fromCharCode(0xfeff) - await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8") + const result = yield* run({ filePath: filepath, oldString: "using System;", newString: "using Up;" }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - const result = await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "using System;", - newString: "using Up;", - }, - ctx, - ), - ) + expect(result.metadata.diff).toContain("-using System;") + expect(result.metadata.diff).toContain("+using Up;") + expect(result.metadata.diff).not.toContain(bom) - expect(result.metadata.diff).toContain("-using System;") - expect(result.metadata.diff).toContain("+using Up;") - expect(result.metadata.diff).not.toContain(bom) + const content = yield* loadRaw(filepath) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\nclass Test {}\n") + }), + ) - const content = await fs.readFile(filepath, "utf-8") - expect(content.charCodeAt(0)).toBe(0xfeff) - expect(content.slice(1)).toBe("using Up;\nclass Test {}\n") - }, - }) - }) + it.instance("throws error when file does not exist", () => + Effect.gen(function* () { + const test = yield* TestInstance + expect( + (yield* fail({ filePath: path.join(test.directory, "nonexistent.txt"), oldString: "old", newString: "new" })) + .message, + ).toContain("not found") + }), + ) - test("throws error when file does not exist", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "nonexistent.txt") + it.instance("throws error when oldString equals newString", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "content") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await expect( - Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "old", - newString: "new", - }, - ctx, - ), - ), - ).rejects.toThrow("not found") - }, - }) - }) + expect((yield* fail({ filePath: filepath, oldString: "same", newString: "same" })).message).toContain( + "identical", + ) + }), + ) - test("throws error when oldString equals newString", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") + it.instance("throws error when oldString not found in file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "actual content") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await expect( - Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "same", - newString: "same", - }, - ctx, - ), - ), - ).rejects.toThrow("identical") - }, - }) - }) + expect(yield* fail({ filePath: filepath, oldString: "not in file", newString: "replacement" })).toBeInstanceOf( + Error, + ) + }), + ) - test("throws error when oldString not found in file", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "actual content", "utf-8") + it.instance("replaces all occurrences with replaceAll option", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "foo bar foo baz foo") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await expect( - Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "not in file", - newString: "replacement", - }, - ctx, - ), - ), - ).rejects.toThrow() - }, - }) - }) + yield* run({ filePath: filepath, oldString: "foo", newString: "qux", replaceAll: true }) - test("replaces all occurrences with replaceAll option", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8") + expect(yield* load(filepath)).toBe("qux bar qux baz qux") + }), + ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "foo", - newString: "qux", - replaceAll: true, - }, - ctx, - ), - ) + it.instance("emits change event for existing files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "original") + const updated = yield* onceBus(FileWatcher.Event.Updated) - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("qux bar qux baz qux") - }, - }) - }) - - test("emits change event for existing files", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "original", "utf-8") - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const { FileWatcher } = await import("../../src/file/watcher") - - const updated = await onceBus(FileWatcher.Event.Updated) - - try { - const edit = await resolve() - await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "original", - newString: "modified", - }, - ctx, - ), - ) - - await updated.wait - } finally { - updated.unsub() - } - }, - }) - }) + yield* run({ filePath: filepath, oldString: "original", newString: "modified" }) + yield* Deferred.await(updated) + }), + ) }) describe("edge cases", () => { - test("handles multiline replacements", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") + it.instance("handles multiline replacements", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "line1\nline2\nline3") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "line2", - newString: "new line 2\nextra line", - }, - ctx, - ), - ) + yield* run({ filePath: filepath, oldString: "line2", newString: "new line 2\nextra line" }) - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("line1\nnew line 2\nextra line\nline3") - }, - }) - }) + expect(yield* load(filepath)).toBe("line1\nnew line 2\nextra line\nline3") + }), + ) - test("handles CRLF line endings", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8") + it.instance("handles CRLF line endings", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "line1\r\nold\r\nline3") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "old", - newString: "new", - }, - ctx, - ), - ) + yield* run({ filePath: filepath, oldString: "old", newString: "new" }) - const content = await fs.readFile(filepath, "utf-8") - expect(content).toBe("line1\r\nnew\r\nline3") - }, - }) - }) + expect(yield* load(filepath)).toBe("line1\r\nnew\r\nline3") + }), + ) - test("throws error when oldString equals newString", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "content", "utf-8") + it.instance("throws error when oldString equals newString", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "content") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await expect( - Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "", - newString: "", - }, - ctx, - ), - ), - ).rejects.toThrow("identical") - }, - }) - }) + expect((yield* fail({ filePath: filepath, oldString: "", newString: "" })).message).toContain("identical") + }), + ) - test("throws error when path is directory", async () => { - await using tmp = await tmpdir() - const dirpath = path.join(tmp.path, "adir") - await fs.mkdir(dirpath) + it.instance("throws error when path is directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + const dirpath = path.join(test.directory, "adir") + yield* makeDirectory(dirpath) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - await expect( - Effect.runPromise( - edit.execute( - { - filePath: dirpath, - oldString: "old", - newString: "new", - }, - ctx, - ), - ), - ).rejects.toThrow("directory") - }, - }) - }) + expect((yield* fail({ filePath: dirpath, oldString: "old", newString: "new" })).message).toContain("directory") + }), + ) - test("tracks file diff statistics", async () => { - await using tmp = await tmpdir() - const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") + it.instance("tracks file diff statistics", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* put(filepath, "line1\nline2\nline3") - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - const result = await Effect.runPromise( - edit.execute( - { - filePath: filepath, - oldString: "line2", - newString: "new line a\nnew line b", - }, - ctx, - ), - ) + const result = yield* run({ filePath: filepath, oldString: "line2", newString: "new line a\nnew line b" }) - expect(result.metadata.filediff).toBeDefined() - expect(result.metadata.filediff.file).toBe(filepath) - expect(result.metadata.filediff.additions).toBeGreaterThan(0) - }, - }) - }) + expect(result.metadata.filediff).toBeDefined() + expect(result.metadata.filediff.file).toBe(filepath) + expect(result.metadata.filediff.additions).toBeGreaterThan(0) + }), + ) }) describe("line endings", () => { @@ -552,149 +349,154 @@ describe("tool.edit", () => { replaceAll?: boolean } - const apply = async (input: Input) => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "test.txt"), input.content) - }, + const apply = Effect.fn("EditToolTest.lineEndings.apply")(function* (input: Input) { + const test = yield* TestInstance + const filePath = path.join(test.directory, "test.txt") + yield* put(filePath, input.content) + yield* run({ + filePath, + oldString: input.oldString, + newString: input.newString, + replaceAll: input.replaceAll, }) - - return await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const edit = await resolve() - const filePath = path.join(tmp.path, "test.txt") - await Effect.runPromise( - edit.execute( - { - filePath, - oldString: input.oldString, - newString: input.newString, - replaceAll: input.replaceAll, - }, - ctx, - ), - ) - return await Bun.file(filePath).text() - }, - }) - } - - test("preserves LF with LF multi-line strings", async () => { - const content = normalize(old + "\n", "\n") - const output = await apply({ - content, - oldString: normalize(old, "\n"), - newString: normalize(next, "\n"), - }) - expect(output).toBe(normalize(next + "\n", "\n")) - expectLf(output) + return yield* load(filePath) }) - test("preserves CRLF with CRLF multi-line strings", async () => { - const content = normalize(old + "\n", "\r\n") - const output = await apply({ - content, - oldString: normalize(old, "\r\n"), - newString: normalize(next, "\r\n"), - }) - expect(output).toBe(normalize(next + "\n", "\r\n")) - expectCrlf(output) - }) + it.instance("preserves LF with LF multi-line strings", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\n") + const output = yield* apply({ + content, + oldString: normalize(old, "\n"), + newString: normalize(next, "\n"), + }) + expect(output).toBe(normalize(next + "\n", "\n")) + expectLf(output) + }), + ) - test("preserves LF when old/new use CRLF", async () => { - const content = normalize(old + "\n", "\n") - const output = await apply({ - content, - oldString: normalize(old, "\r\n"), - newString: normalize(next, "\r\n"), - }) - expect(output).toBe(normalize(next + "\n", "\n")) - expectLf(output) - }) + it.instance("preserves CRLF with CRLF multi-line strings", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\r\n") + const output = yield* apply({ + content, + oldString: normalize(old, "\r\n"), + newString: normalize(next, "\r\n"), + }) + expect(output).toBe(normalize(next + "\n", "\r\n")) + expectCrlf(output) + }), + ) - test("preserves CRLF when old/new use LF", async () => { - const content = normalize(old + "\n", "\r\n") - const output = await apply({ - content, - oldString: normalize(old, "\n"), - newString: normalize(next, "\n"), - }) - expect(output).toBe(normalize(next + "\n", "\r\n")) - expectCrlf(output) - }) + it.instance("preserves LF when old/new use CRLF", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\n") + const output = yield* apply({ + content, + oldString: normalize(old, "\r\n"), + newString: normalize(next, "\r\n"), + }) + expect(output).toBe(normalize(next + "\n", "\n")) + expectLf(output) + }), + ) - test("preserves LF when newString uses CRLF", async () => { - const content = normalize(old + "\n", "\n") - const output = await apply({ - content, - oldString: normalize(old, "\n"), - newString: normalize(next, "\r\n"), - }) - expect(output).toBe(normalize(next + "\n", "\n")) - expectLf(output) - }) + it.instance("preserves CRLF when old/new use LF", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\r\n") + const output = yield* apply({ + content, + oldString: normalize(old, "\n"), + newString: normalize(next, "\n"), + }) + expect(output).toBe(normalize(next + "\n", "\r\n")) + expectCrlf(output) + }), + ) - test("preserves CRLF when newString uses LF", async () => { - const content = normalize(old + "\n", "\r\n") - const output = await apply({ - content, - oldString: normalize(old, "\r\n"), - newString: normalize(next, "\n"), - }) - expect(output).toBe(normalize(next + "\n", "\r\n")) - expectCrlf(output) - }) + it.instance("preserves LF when newString uses CRLF", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\n") + const output = yield* apply({ + content, + oldString: normalize(old, "\n"), + newString: normalize(next, "\r\n"), + }) + expect(output).toBe(normalize(next + "\n", "\n")) + expectLf(output) + }), + ) - test("preserves LF with mixed old/new line endings", async () => { - const content = normalize(old + "\n", "\n") - const output = await apply({ - content, - oldString: "alpha\nbeta\r\ngamma", - newString: "alpha\r\nbeta\nomega", - }) - expect(output).toBe(normalize(alt + "\n", "\n")) - expectLf(output) - }) + it.instance("preserves CRLF when newString uses LF", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\r\n") + const output = yield* apply({ + content, + oldString: normalize(old, "\r\n"), + newString: normalize(next, "\n"), + }) + expect(output).toBe(normalize(next + "\n", "\r\n")) + expectCrlf(output) + }), + ) - test("preserves CRLF with mixed old/new line endings", async () => { - const content = normalize(old + "\n", "\r\n") - const output = await apply({ - content, - oldString: "alpha\r\nbeta\ngamma", - newString: "alpha\nbeta\r\nomega", - }) - expect(output).toBe(normalize(alt + "\n", "\r\n")) - expectCrlf(output) - }) + it.instance("preserves LF with mixed old/new line endings", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\n") + const output = yield* apply({ + content, + oldString: "alpha\nbeta\r\ngamma", + newString: "alpha\r\nbeta\nomega", + }) + expect(output).toBe(normalize(alt + "\n", "\n")) + expectLf(output) + }), + ) - test("replaceAll preserves LF for multi-line blocks", async () => { - const blockOld = "alpha\nbeta" - const blockNew = "alpha\nbeta-updated" - const content = normalize(blockOld + "\n" + blockOld + "\n", "\n") - const output = await apply({ - content, - oldString: normalize(blockOld, "\n"), - newString: normalize(blockNew, "\n"), - replaceAll: true, - }) - expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n")) - expectLf(output) - }) + it.instance("preserves CRLF with mixed old/new line endings", () => + Effect.gen(function* () { + const content = normalize(old + "\n", "\r\n") + const output = yield* apply({ + content, + oldString: "alpha\r\nbeta\ngamma", + newString: "alpha\nbeta\r\nomega", + }) + expect(output).toBe(normalize(alt + "\n", "\r\n")) + expectCrlf(output) + }), + ) - test("replaceAll preserves CRLF for multi-line blocks", async () => { - const blockOld = "alpha\nbeta" - const blockNew = "alpha\nbeta-updated" - const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n") - const output = await apply({ - content, - oldString: normalize(blockOld, "\r\n"), - newString: normalize(blockNew, "\r\n"), - replaceAll: true, - }) - expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n")) - expectCrlf(output) - }) + it.instance("replaceAll preserves LF for multi-line blocks", () => + Effect.gen(function* () { + const blockOld = "alpha\nbeta" + const blockNew = "alpha\nbeta-updated" + const content = normalize(blockOld + "\n" + blockOld + "\n", "\n") + const output = yield* apply({ + content, + oldString: normalize(blockOld, "\n"), + newString: normalize(blockNew, "\n"), + replaceAll: true, + }) + expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n")) + expectLf(output) + }), + ) + + it.instance("replaceAll preserves CRLF for multi-line blocks", () => + Effect.gen(function* () { + const blockOld = "alpha\nbeta" + const blockNew = "alpha\nbeta-updated" + const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n") + const output = yield* apply({ + content, + oldString: normalize(blockOld, "\r\n"), + newString: normalize(blockNew, "\r\n"), + replaceAll: true, + }) + expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n")) + expectCrlf(output) + }), + ) }) describe("concurrent editing", () => { From 0f5d4ae648241e459e24bc825190fac575dcffa6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 22:12:07 -0400 Subject: [PATCH 22/32] test(project): stabilize VCS branch update test (#26979) --- packages/opencode/test/project/vcs.test.ts | 27 +++++++++++++++------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 75d1feadd0..b1d637302d 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { parsePatch } from "diff" -import { Deferred, Effect, Layer, Stream } from "effect" +import { Deferred, Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import fs from "fs/promises" import path from "path" @@ -50,13 +50,26 @@ const nextBranchUpdate = Effect.fn("VcsTest.nextBranchUpdate")(function* () { const bus = yield* Bus.Service const updated = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribe(Vcs.Event.BranchUpdated), (evt) => - Deferred.succeed(updated, evt.properties.branch), - ).pipe(Effect.forkScoped) + const off = yield* bus.subscribeCallback(Vcs.Event.BranchUpdated, (evt) => { + Effect.runSync(Deferred.succeed(updated, evt.properties.branch)) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) return updated }) +const publishHeadChangeUntil = Effect.fn("VcsTest.publishHeadChangeUntil")(function* ( + pending: Deferred.Deferred, + head: string, +) { + const bus = yield* Bus.Service + for (let i = 0; i < 50; i++) { + yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" }) + if (yield* Deferred.isDone(pending)) return + yield* Effect.sleep("10 millis") + } +}) + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -99,11 +112,10 @@ describe("Vcs", () => { const vcs = yield* init() yield* vcs.branch() const pending = yield* nextBranchUpdate() - const bus = yield* Bus.Service const head = path.join(test.directory, ".git", "HEAD") yield* write(head, `ref: refs/heads/${branch}\n`) - yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" }) + yield* publishHeadChangeUntil(pending, head) const updated = yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds")) expect(updated).toBe(branch) @@ -122,11 +134,10 @@ describe("Vcs", () => { const vcs = yield* init() yield* vcs.branch() const pending = yield* nextBranchUpdate() - const bus = yield* Bus.Service const head = path.join(test.directory, ".git", "HEAD") yield* write(head, `ref: refs/heads/${branch}\n`) - yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" }) + yield* publishHeadChangeUntil(pending, head) yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds")) const current = yield* vcs.branch() From cc1835e0dbc9ce9f4dbbd92dbc01a30518d0cc40 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 11 May 2026 22:23:52 -0400 Subject: [PATCH 23/32] test(provider): migrate config-backed cases to Effect runner (#26969) --- .../opencode/test/provider/provider.test.ts | 131 ++++++++---------- 1 file changed, 59 insertions(+), 72 deletions(-) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index cdb9d20572..ea65c90c4f 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -15,6 +15,7 @@ import { Env } from "../../src/env" import { Effect } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" import { makeRuntime } from "../../src/effect/run-service" +import { testEffect } from "../lib/effect" const env = makeRuntime(Env.Service, Env.defaultLayer) const set = (k: string, v: string) => env.runSync((svc) => svc.set(k, v)) @@ -70,6 +71,8 @@ function paid(providers: Awaited>) { return Object.values(item.models).filter((model) => model.cost.input > 0).length } +const it = testEffect(Provider.defaultLayer) + test("provider loaded from env variable", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -515,85 +518,69 @@ test("defaultModel respects config model setting", async () => { }) }) -test("provider with baseURL from config", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - provider: { - "custom-openai": { - name: "Custom OpenAI", - npm: "@ai-sdk/openai-compatible", - env: [], - models: { - "gpt-4": { - name: "GPT-4", - tool_call: true, - limit: { context: 128000, output: 4096 }, - }, - }, - options: { - apiKey: "test-key", - baseURL: "https://custom.openai.com/v1", - }, +it.instance( + "provider with baseURL from config", + Effect.gen(function* () { + const providers = yield* Provider.Service.use((provider) => provider.list()) + expect(providers[ProviderID.make("custom-openai")]).toBeDefined() + expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1") + }), + { + config: { + provider: { + "custom-openai": { + name: "Custom OpenAI", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { + "gpt-4": { + name: "GPT-4", + tool_call: true, + limit: { context: 128000, output: 4096 }, }, }, - }), - ) + options: { + apiKey: "test-key", + baseURL: "https://custom.openai.com/v1", + }, + }, + }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const providers = await list() - expect(providers[ProviderID.make("custom-openai")]).toBeDefined() - expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1") - }, - }) -}) + }, +) -test("model cost defaults to zero when not specified", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - provider: { - "test-provider": { - name: "Test Provider", - npm: "@ai-sdk/openai-compatible", - env: [], - models: { - "test-model": { - name: "Test Model", - tool_call: true, - limit: { context: 128000, output: 4096 }, - }, - }, - options: { - apiKey: "test-key", - }, +it.instance( + "model cost defaults to zero when not specified", + Effect.gen(function* () { + const providers = yield* Provider.Service.use((provider) => provider.list()) + const model = providers[ProviderID.make("test-provider")].models["test-model"] + expect(model.cost.input).toBe(0) + expect(model.cost.output).toBe(0) + expect(model.cost.cache.read).toBe(0) + expect(model.cost.cache.write).toBe(0) + }), + { + config: { + provider: { + "test-provider": { + name: "Test Provider", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { + "test-model": { + name: "Test Model", + tool_call: true, + limit: { context: 128000, output: 4096 }, }, }, - }), - ) + options: { + apiKey: "test-key", + }, + }, + }, }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const providers = await list() - const model = providers[ProviderID.make("test-provider")].models["test-model"] - expect(model.cost.input).toBe(0) - expect(model.cost.output).toBe(0) - expect(model.cost.cache.read).toBe(0) - expect(model.cost.cache.write).toBe(0) - }, - }) -}) + }, +) test("model options are merged from existing model", async () => { await using tmp = await tmpdir({ From 78a2639e5ed83571282268e449b812ba5f1a2f05 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 12 May 2026 10:38:21 +0800 Subject: [PATCH 24/32] fix(app): open next project when closing current one (#26987) --- packages/app/src/pages/layout.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 45fcc6ee27..68a17f73ce 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1409,19 +1409,20 @@ export default function Layout(props: ParentProps) { const index = list.findIndex((x) => pathKey(x.worktree) === key) const active = pathKey(currentProject()?.worktree ?? "") === key if (index === -1) return - const next = list[index + 1] if (!active) { layout.projects.close(directory) return } - if (!next) { + if (list.length === 1) { layout.projects.close(directory) navigate("/") return } + const next = list[index + 1] ?? list[index - 1] + navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`) layout.projects.close(directory) queueMicrotask(() => { From 871374804f1ed989dc49b0a296ce39f0df4ca588 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 12 May 2026 10:39:36 +0800 Subject: [PATCH 25/32] fix(app): use keyed Show for project in layout (#26985) --- packages/app/src/pages/layout.tsx | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 68a17f73ce..11bc4fdb5d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2097,6 +2097,7 @@ export default function Layout(props: ParentProps) {
} + keyed > {(project) => ( <> @@ -2107,9 +2108,7 @@ export default function Layout(props: ParentProps) { id={`project:${projectId()}`} value={projectName} onSave={(next) => { - const item = project() - if (!item) return - void renameProject(item, next) + void renameProject(project, next) }} class="text-14-medium text-text-strong truncate" displayClass="text-14-medium text-text-strong truncate" @@ -2151,9 +2150,7 @@ export default function Layout(props: ParentProps) { { - const item = project() - if (!item) return - showEditProjectDialog(item) + showEditProjectDialog(project) }} > {language.t("common.edit")} @@ -2163,9 +2160,7 @@ export default function Layout(props: ParentProps) { data-project={slug()} disabled={!canToggle()} onSelect={() => { - const item = project() - if (!item) return - toggleProjectWorkspaces(item) + toggleProjectWorkspaces(project) }} > @@ -2224,7 +2219,7 @@ export default function Layout(props: ParentProps) {
@@ -2239,9 +2234,7 @@ export default function Layout(props: ParentProps) { icon="plus-small" class="w-full" onClick={() => { - const item = project() - if (!item) return - void createWorkspace(item) + void createWorkspace(project) }} > {language.t("workspace.new")} @@ -2268,7 +2261,7 @@ export default function Layout(props: ParentProps) { From 1a28924ed89b98ee96e644d0e6e8e4a6a8bcfccc Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 11 May 2026 21:47:35 -0500 Subject: [PATCH 26/32] fix: grep external directory permission evaluation (#26958) --- packages/opencode/src/tool/grep.ts | 21 +++++----- packages/opencode/test/tool/grep.test.ts | 53 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 4e89198dff..01aa6a0b72 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -54,19 +54,20 @@ export const GrepTool = Tool.define( }) const ins = yield* InstanceState.context - const search = AppFileSystem.resolve( - path.isAbsolute(params.path ?? ins.directory) - ? (params.path ?? ins.directory) - : path.join(ins.directory, params.path ?? "."), - ) - yield* reference.ensure(search) + const requested = path.isAbsolute(params.path ?? ins.directory) + ? (params.path ?? ins.directory) + : path.join(ins.directory, params.path ?? ".") + yield* reference.ensure(requested) + const requestedInfo = yield* fs.stat(requested).pipe(Effect.catch(() => Effect.succeed(undefined))) + yield* assertExternalDirectoryEffect(ctx, requested, { + bypass: yield* reference.contains(requested), + kind: requestedInfo?.type === "Directory" ? "directory" : "file", + }) + + const search = AppFileSystem.resolve(requested) const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) const cwd = info?.type === "Directory" ? search : path.dirname(search) const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)] - yield* assertExternalDirectoryEffect(ctx, search, { - bypass: yield* reference.contains(search), - kind: info?.type === "Directory" ? "directory" : "file", - }) const result = yield* rg.search({ cwd, diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 53f5d9a19c..29b5a60db2 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -1,4 +1,6 @@ import { describe, expect } from "bun:test" +import fs from "fs/promises" +import os from "os" import path from "path" import { Effect, Layer } from "effect" import { GrepTool } from "../../src/tool/grep" @@ -11,6 +13,8 @@ import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { testEffect } from "../lib/effect" import { Reference } from "@/reference/reference" +import { Permission } from "../../src/permission" +import type * as Tool from "../../src/tool/tool" const it = testEffect( Layer.mergeAll( @@ -110,4 +114,53 @@ describe("tool.grep", () => { expect(result.output).toContain("Line 2: line2") }), ) + + it.instance("does not ask for external_directory when alias path is allowed", () => + Effect.gen(function* () { + if (process.platform === "win32") return + + yield* TestInstance + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "opencode-grep-alias-"))), + (dir) => Effect.promise(() => fs.rm(dir, { recursive: true, force: true })), + ) + const real = path.join(tmp, "real") + const alias = path.join(tmp, "alias") + yield* Effect.promise(() => fs.mkdir(real)) + yield* Effect.promise(() => fs.symlink(real, alias, "dir")) + yield* Effect.promise(() => Bun.write(path.join(real, "test.txt"), "needle")) + + const ruleset = Permission.fromConfig({ + grep: "allow", + external_directory: { + [path.join(alias, "*")]: "allow", + }, + }) + const requests: Array> = [] + const next: Tool.Context = { + ...ctx, + ask: (req) => + Effect.sync(() => { + const needsAsk = req.patterns.some( + (pattern) => Permission.evaluate(req.permission, pattern, ruleset).action !== "allow", + ) + if (needsAsk) requests.push(req) + }), + } + + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "needle", + path: alias, + include: "*.txt", + }, + next, + ) + + expect(result.metadata.matches).toBe(1) + expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined() + }), + ) }) From ddce77622576f2800d7c79b25ad9c15de8b3a289 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 11 May 2026 22:25:41 -0500 Subject: [PATCH 27/32] ignore: add codebase skill to repo (#26990) --- .../DEEPENING.md | 37 ++++++++++ .../INTERFACE-DESIGN.md | 44 ++++++++++++ .../improve-codebase-architecture/LANGUAGE.md | 53 ++++++++++++++ .../improve-codebase-architecture/SKILL.md | 71 +++++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 .opencode/skills/improve-codebase-architecture/DEEPENING.md create mode 100644 .opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md create mode 100644 .opencode/skills/improve-codebase-architecture/LANGUAGE.md create mode 100644 .opencode/skills/improve-codebase-architecture/SKILL.md diff --git a/.opencode/skills/improve-codebase-architecture/DEEPENING.md b/.opencode/skills/improve-codebase-architecture/DEEPENING.md new file mode 100644 index 0000000000..ecaf5d7dcf --- /dev/null +++ b/.opencode/skills/improve-codebase-architecture/DEEPENING.md @@ -0,0 +1,37 @@ +# Deepening + +How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**. + +## Dependency categories + +When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam. + +### 1. In-process + +Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed. + +### 2. Local-substitutable + +Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface. + +### 3. Remote but owned (Ports & Adapters) + +Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter. + +Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."* + +### 4. True external (Mock) + +Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter. + +## Seam discipline + +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection. +- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them. + +## Testing strategy: replace, don't layer + +- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them. +- Write new tests at the deepened module's interface. The **interface is the test surface**. +- Tests assert on observable outcomes through the interface, not internal state. +- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface. diff --git a/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md b/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md new file mode 100644 index 0000000000..3197723a0d --- /dev/null +++ b/.opencode/skills/improve-codebase-architecture/INTERFACE-DESIGN.md @@ -0,0 +1,44 @@ +# Interface Design + +When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best. + +Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**. + +## Process + +### 1. Frame the problem space + +Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate: + +- The constraints any new interface would need to satisfy +- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md)) +- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete + +Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel. + +### 2. Spawn sub-agents + +Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module. + +Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint: + +- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point." +- Agent 2: "Maximise flexibility — support many use cases and extension." +- Agent 3: "Optimise for the most common caller — make the default case trivial." +- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies." + +Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language. + +Each sub-agent outputs: + +1. Interface (types, methods, params — plus invariants, ordering, error modes) +2. Usage example showing how callers use it +3. What the implementation hides behind the seam +4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md)) +5. Trade-offs — where leverage is high, where it's thin + +### 3. Present and compare + +Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**. + +After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu. diff --git a/.opencode/skills/improve-codebase-architecture/LANGUAGE.md b/.opencode/skills/improve-codebase-architecture/LANGUAGE.md new file mode 100644 index 0000000000..530c27630a --- /dev/null +++ b/.opencode/skills/improve-codebase-architecture/LANGUAGE.md @@ -0,0 +1,53 @@ +# Language + +Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point. + +## Terms + +**Module** +Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice. +_Avoid_: unit, component, service. + +**Interface** +Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics. +_Avoid_: API, signature (too narrow — those refer only to the type-level surface). + +**Implementation** +What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise. + +**Depth** +Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation. + +**Seam** _(from Michael Feathers)_ +A place where you can alter behaviour without editing in that place. The *location* at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it. +_Avoid_: boundary (overloaded with DDD's bounded context). + +**Adapter** +A concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside). + +**Leverage** +What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests. + +**Locality** +What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere. + +## Principles + +- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface. +- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep. +- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape. +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. + +## Relationships + +- A **Module** has exactly one **Interface** (the surface it presents to callers and tests). +- **Depth** is a property of a **Module**, measured against its **Interface**. +- A **Seam** is where a **Module**'s **Interface** lives. +- An **Adapter** sits at a **Seam** and satisfies the **Interface**. +- **Depth** produces **Leverage** for callers and **Locality** for maintainers. + +## Rejected framings + +- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead. +- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know. +- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**. diff --git a/.opencode/skills/improve-codebase-architecture/SKILL.md b/.opencode/skills/improve-codebase-architecture/SKILL.md new file mode 100644 index 0000000000..05984a6096 --- /dev/null +++ b/.opencode/skills/improve-codebase-architecture/SKILL.md @@ -0,0 +1,71 @@ +--- +name: improve-codebase-architecture +description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable. +--- + +# Improve Codebase Architecture + +Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability. + +## Glossary + +Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md). + +- **Module** — anything with an interface and an implementation (function, class, package, slice). +- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature. +- **Implementation** — the code inside. +- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation. +- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.") +- **Adapter** — a concrete thing satisfying an interface at a seam. +- **Leverage** — what callers get from depth. +- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place. + +Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list): + +- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. +- **The interface is the test surface.** +- **One adapter = hypothetical seam. Two adapters = real seam.** + +This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate. + +## Process + +### 1. Explore + +Read the project's domain glossary and any ADRs in the area you're touching first. + +Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction: + +- Where does understanding one concept require bouncing between many small modules? +- Where are modules **shallow** — interface nearly as complex as the implementation? +- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)? +- Where do tightly-coupled modules leak across their seams? +- Which parts of the codebase are untested, or hard to test through their current interface? + +Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want. + +### 2. Present candidates + +Present a numbered list of deepening opportunities. For each candidate: + +- **Files** — which files/modules are involved +- **Problem** — why the current architecture is causing friction +- **Solution** — plain English description of what would change +- **Benefits** — explained in terms of locality and leverage, and also in how tests would improve + +**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." + +**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. + +Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?" + +### 3. Grilling loop + +Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive. + +Side effects happen inline as decisions crystallize: + +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist. +- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. +- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md). +- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). From 591eb667d5c3c28034bea0b2d3870625feb809eb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 03:26:47 +0000 Subject: [PATCH 28/32] chore: generate --- .opencode/skills/improve-codebase-architecture/DEEPENING.md | 2 +- .opencode/skills/improve-codebase-architecture/LANGUAGE.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.opencode/skills/improve-codebase-architecture/DEEPENING.md b/.opencode/skills/improve-codebase-architecture/DEEPENING.md index ecaf5d7dcf..c52fdfd99f 100644 --- a/.opencode/skills/improve-codebase-architecture/DEEPENING.md +++ b/.opencode/skills/improve-codebase-architecture/DEEPENING.md @@ -18,7 +18,7 @@ Dependencies that have local test stand-ins (PGLite for Postgres, in-memory file Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter. -Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."* +Recommendation shape: _"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."_ ### 4. True external (Mock) diff --git a/.opencode/skills/improve-codebase-architecture/LANGUAGE.md b/.opencode/skills/improve-codebase-architecture/LANGUAGE.md index 530c27630a..dd9b60fea0 100644 --- a/.opencode/skills/improve-codebase-architecture/LANGUAGE.md +++ b/.opencode/skills/improve-codebase-architecture/LANGUAGE.md @@ -19,11 +19,11 @@ What's inside a module — its body of code. Distinct from **Adapter**: a thing Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation. **Seam** _(from Michael Feathers)_ -A place where you can alter behaviour without editing in that place. The *location* at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it. +A place where you can alter behaviour without editing in that place. The _location_ at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it. _Avoid_: boundary (overloaded with DDD's bounded context). **Adapter** -A concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside). +A concrete thing that satisfies an interface at a seam. Describes _role_ (what slot it fills), not substance (what's inside). **Leverage** What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests. @@ -35,7 +35,7 @@ What maintainers get from depth. Change, bugs, knowledge, and verification conce - **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface. - **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep. -- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape. +- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test _past_ the interface, the module is probably the wrong shape. - **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. ## Relationships From 2b432d9e036ef5871e169b00816cb5e5ddf3a77c Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 11 May 2026 23:42:04 -0400 Subject: [PATCH 29/32] fix(tui): scope events by project (#26936) --- .../opencode/src/cli/cmd/tui/context/event.ts | 32 ++++------ .../opencode/src/cli/cmd/tui/context/sync.tsx | 6 +- .../test/cli/cmd/tui/sync-fixture.tsx | 33 ++++++++-- .../opencode/test/cli/cmd/tui/sync.test.tsx | 42 ++++++++++++- .../opencode/test/cli/tui/use-event.test.tsx | 63 +++++++++++-------- 5 files changed, 122 insertions(+), 54 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/event.ts b/packages/opencode/src/cli/cmd/tui/context/event.ts index 156f9c9476..5d814ecdca 100644 --- a/packages/opencode/src/cli/cmd/tui/context/event.ts +++ b/packages/opencode/src/cli/cmd/tui/context/event.ts @@ -2,39 +2,33 @@ import type { Event } from "@opencode-ai/sdk/v2" import { useProject } from "./project" import { useSDK } from "./sdk" +type EventMetadata = { + workspace: string | undefined +} + export function useEvent() { const project = useProject() const sdk = useSDK() - function subscribe(handler: (event: Event) => void) { + function subscribe(handler: (event: Event, metadata: EventMetadata) => void) { return sdk.event.on("event", (event) => { if (event.payload.type === "sync") { return } - // Special hack for truly global events - if (event.directory === "global") { - handler(event.payload) - } - - if (project.workspace.current()) { - if (event.workspace === project.workspace.current()) { - handler(event.payload) - } - - return - } - - if (event.directory === project.instance.directory()) { - handler(event.payload) + if (event.directory === "global" || event.project === project.project()) { + handler(event.payload, { workspace: event.workspace }) } }) } - function on(type: T, handler: (event: Extract) => void) { - return subscribe((event) => { + function on( + type: T, + handler: (event: Extract, metadata: EventMetadata) => void, + ) { + return subscribe((event: Event, metadata: EventMetadata) => { if (event.type !== type) return - handler(event as Extract) + handler(event as Extract, metadata) }) } diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 0d4cb2e6e2..76b1807abd 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -131,7 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) } - event.subscribe((event) => { + event.subscribe((event, { workspace }) => { switch (event.type) { case "server.instance.disposed": void bootstrap() @@ -364,7 +364,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "vcs.branch.updated": { - setStore("vcs", { branch: event.properties.branch }) + if (workspace === project.workspace.current()) { + setStore("vcs", { branch: event.properties.branch }) + } break } } diff --git a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx index d9ecdbe9d5..5f51374c16 100644 --- a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx @@ -4,9 +4,10 @@ import { onMount } from "solid-js" import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" -import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" +import { ProjectProvider, useProject } from "../../../../src/cli/cmd/tui/context/project" import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" export const worktree = "/tmp/opencode" export const directory = `${worktree}/packages/opencode` @@ -30,6 +31,25 @@ export function eventSource(): EventSource { return { subscribe: async () => () => {} } } +export function createEventSource() { + let fn: ((event: GlobalEvent) => void) | undefined + + return { + source: { + subscribe: async (handler: (event: GlobalEvent) => void) => { + fn = handler + return () => { + if (fn === handler) fn = undefined + } + }, + } satisfies EventSource, + emit(event: GlobalEvent) { + if (!fn) throw new Error("event source not ready") + fn(event) + }, + } +} + type FetchHandler = (url: URL) => Response | Promise | undefined export function createFetch(override?: FetchHandler) { @@ -77,11 +97,13 @@ export function createFetch(override?: FetchHandler) { return { fetch, session } } -type Ctx = { kv: ReturnType; sync: ReturnType } +type Ctx = { kv: ReturnType; project: ReturnType; sync: ReturnType } export async function mount(override?: FetchHandler) { const calls = createFetch(override) + const events = createEventSource() let sync!: ReturnType + let project!: ReturnType let kv!: ReturnType let done!: () => void const ready = new Promise((resolve) => { @@ -89,9 +111,10 @@ export async function mount(override?: FetchHandler) { }) function Probe() { - const ctx: Ctx = { kv: useKV(), sync: useSync() } + const ctx: Ctx = { kv: useKV(), project: useProject(), sync: useSync() } onMount(() => { sync = ctx.sync + project = ctx.project kv = ctx.kv done() }) @@ -102,7 +125,7 @@ export async function mount(override?: FetchHandler) { - + @@ -116,5 +139,5 @@ export async function mount(override?: FetchHandler) { await ready await wait(() => sync.status === "complete") - return { app, kv, sync, session: calls.session } + return { app, emit: events.emit, kv, project, sync, session: calls.session } } diff --git a/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/packages/opencode/test/cli/cmd/tui/sync.test.tsx index f67257f6ce..714c39a781 100644 --- a/packages/opencode/test/cli/cmd/tui/sync.test.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -2,7 +2,21 @@ import { describe, expect, test } from "bun:test" import { Global } from "@opencode-ai/core/global" import { tmpdir } from "../../../fixture/fixture" -import { mount } from "./sync-fixture" +import { mount, wait } from "./sync-fixture" +import type { GlobalEvent } from "@opencode-ai/sdk/v2" + +function branchEvent(branch: string, workspace?: string): GlobalEvent { + return { + directory: "/tmp/other", + project: "proj_test", + workspace, + payload: { + id: `evt_vcs_${branch}`, + type: "vcs.branch.updated", + properties: { branch }, + }, + } +} describe("tui sync", () => { test("refresh scopes sessions by default and lists project sessions when disabled", async () => { @@ -27,4 +41,30 @@ describe("tui sync", () => { Global.Path.state = previous } }) + + test("vcs branch updates only apply for the active workspace", async () => { + const previous = Global.Path.state + await using tmp = await tmpdir() + Global.Path.state = tmp.path + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, emit, project, sync } = await mount() + + try { + expect(sync.data.vcs?.branch).toBe("main") + + project.workspace.set("ws_a") + emit(branchEvent("other", "ws_b")) + await Bun.sleep(30) + + expect(sync.data.vcs?.branch).toBe("main") + + emit(branchEvent("feature", "ws_a")) + await wait(() => sync.data.vcs?.branch === "feature") + + expect(sync.data.vcs?.branch).toBe("feature") + } finally { + app.renderer.destroy() + Global.Path.state = previous + } + }) }) diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx index 78253361b7..ac2d942db6 100644 --- a/packages/opencode/test/cli/tui/use-event.test.tsx +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -7,6 +7,8 @@ import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/pr import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" import { useEvent } from "../../../src/cli/cmd/tui/context/event" +const projectID = "proj_test" + async function wait(fn: () => boolean, timeout = 2000) { const start = Date.now() while (!fn()) { @@ -15,9 +17,10 @@ async function wait(fn: () => boolean, timeout = 2000) { } } -function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent { +function event(payload: Event, input: { directory: string; project?: string; workspace?: string }): GlobalEvent { return { directory: input.directory, + project: input.project, workspace: input.workspace, payload, } @@ -65,6 +68,13 @@ function createSource() { async function mount() { const source = createSource() const seen: Event[] = [] + const workspaces: Array = [] + const fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + if (url.pathname === "/path") return Response.json({ home: "", state: "", config: "", directory: "/tmp/root" }) + if (url.pathname === "/project/current") return Response.json({ id: projectID }) + throw new Error(`unexpected request: ${url.pathname}`) + }) as typeof globalThis.fetch let project!: ReturnType let done!: () => void const ready = new Promise((resolve) => { @@ -72,30 +82,42 @@ async function mount() { }) const app = await testRender(() => ( - + { + onReady={async (ctx) => { project = ctx.project + await project.sync() done() }} seen={seen} + workspaces={workspaces} /> )) await ready - return { app, emit: source.emit, project, seen } + return { app, emit: source.emit, project, seen, workspaces } } -function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType }) => void }) { +function Probe(props: { + seen: Event[] + workspaces: Array + onReady: (ctx: { project: ReturnType }) => void +}) { const project = useProject() const event = useEvent() onMount(() => { - event.subscribe((evt) => { + event.subscribe((evt, { workspace }) => { props.seen.push(evt) + props.workspaces.push(workspace) }) props.onReady({ project }) }) @@ -104,25 +126,26 @@ function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType { - test("delivers matching directory events without an active workspace", async () => { - const { app, emit, seen } = await mount() + test("delivers events for the current project", async () => { + const { app, emit, seen, workspaces } = await mount() try { - emit(event(vcs("main"), { directory: "/tmp/root" })) + emit(event(vcs("main"), { directory: "/tmp/other", project: projectID, workspace: "ws_a" })) await wait(() => seen.length === 1) expect(seen).toEqual([vcs("main")]) + expect(workspaces).toEqual(["ws_a"]) } finally { app.renderer.destroy() } }) - test("ignores non-matching directory events without an active workspace", async () => { + test("ignores events for other projects", async () => { const { app, emit, seen } = await mount() try { - emit(event(vcs("other"), { directory: "/tmp/other" })) + emit(event(vcs("other"), { directory: "/tmp/root", project: "proj_other" })) await Bun.sleep(30) expect(seen).toHaveLength(0) @@ -131,12 +154,12 @@ describe("useEvent", () => { } }) - test("delivers matching workspace events when a workspace is active", async () => { + test("delivers current project events regardless of active workspace", async () => { const { app, emit, project, seen } = await mount() try { project.workspace.set("ws_a") - emit(event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_a" })) + emit(event(vcs("ws"), { directory: "/tmp/other", project: projectID, workspace: "ws_b" })) await wait(() => seen.length === 1) @@ -146,20 +169,6 @@ describe("useEvent", () => { } }) - test("ignores non-matching workspace events when a workspace is active", async () => { - const { app, emit, project, seen } = await mount() - - try { - project.workspace.set("ws_a") - emit(event(vcs("ws"), { directory: "/tmp/root", workspace: "ws_b" })) - await Bun.sleep(30) - - expect(seen).toHaveLength(0) - } finally { - app.renderer.destroy() - } - }) - test("delivers truly global events even when a workspace is active", async () => { const { app, emit, project, seen } = await mount() From 5cc84800dc236353c839d845ae2403482d3bac11 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 12 May 2026 03:43:40 +0000 Subject: [PATCH 30/32] chore: generate --- packages/opencode/test/cli/tui/use-event.test.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx index ac2d942db6..d690cfd6ce 100644 --- a/packages/opencode/test/cli/tui/use-event.test.tsx +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -82,12 +82,7 @@ async function mount() { }) const app = await testRender(() => ( - + { From 487575773d2d51fb541eea1fb40f647a3bbf62cf Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 11 May 2026 23:13:22 -0500 Subject: [PATCH 31/32] feat: create global opencode.jsonc if no configs exist (#26992) --- packages/opencode/src/config/config.ts | 10 ++++ packages/opencode/test/config/config.test.ts | 48 ++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 4b10665aca..e44405f42e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -409,6 +409,16 @@ export const layer = Layer.effect( const loadGlobal = Effect.fnUntraced(function* () { let result: Info = {} + // Seed the default global config with the schema for editor completion, but avoid writing when the user + // explicitly routes config through env-provided paths or content. + if (!Flag.OPENCODE_CONFIG && !Flag.OPENCODE_CONFIG_DIR && !Flag.OPENCODE_CONFIG_CONTENT) { + const file = globalConfigFile() + if (!existsSync(file)) { + yield* fs + .writeWithDirs(file, JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2)) + .pipe(Effect.catch(() => Effect.void)) + } + } result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"))) result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"))) result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index fa9fd332e8..90e78efcdb 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -141,6 +141,54 @@ test("loads config with defaults when no files exist", async () => { }) }) +test("creates global jsonc config with schema when no global configs exist", async () => { + await using tmp = await tmpdir() + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear(true) + + try { + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + await load() + }, + }) + + const content = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc")) + expect(content).toContain('"$schema": "https://opencode.ai/config.json"') + } finally { + ;(Global.Path as { config: string }).config = prev + await clear(true) + } +}) + +test("does not create global config when OPENCODE_CONFIG_DIR is set", async () => { + await using tmp = await tmpdir() + await using custom = await tmpdir() + const prevConfig = Global.Path.config + const prevEnv = process.env.OPENCODE_CONFIG_DIR + ;(Global.Path as { config: string }).config = tmp.path + process.env.OPENCODE_CONFIG_DIR = custom.path + await clear(true) + + try { + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + await load() + }, + }) + + expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc"))).toBe(false) + } finally { + ;(Global.Path as { config: string }).config = prevConfig + if (prevEnv === undefined) delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = prevEnv + await clear(true) + } +}) + test("loads JSON config file", async () => { await using tmp = await tmpdir({ init: async (dir) => { From e36bc20f844fe3aa1ee581502cc921ebba072d3d Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 12 May 2026 00:30:03 -0400 Subject: [PATCH 32/32] fix(tui): fix flicker by avoiding redundant workspace session sync (#26997) --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 5 ----- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 3 ++- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 76b1807abd..31104ddd9c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -113,7 +113,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const kv = useKV() const fullSyncedSessions = new Set() - let syncedWorkspace = project.workspace.current() function sessionListQuery(): { scope?: "project"; path?: string } { if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" } @@ -378,10 +377,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ async function bootstrap(input: { fatal?: boolean } = {}) { const fatal = input.fatal ?? true const workspace = project.workspace.current() - if (workspace !== syncedWorkspace) { - fullSyncedSessions.clear() - syncedWorkspace = workspace - } const projectPromise = project.sync() const sessionListPromise = projectPromise.then(() => listSessions()) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b2ee3af622..3e966d9a58 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -10,6 +10,7 @@ import { onMount, Show, Switch, + untrack, useContext, } from "solid-js" import { Dynamic } from "solid-js/web" @@ -242,7 +243,7 @@ export function Session() { createEffect(() => { const sessionID = route.sessionID void (async () => { - const previousWorkspace = project.workspace.current() + const previousWorkspace = untrack(() => project.workspace.current()) const result = await sdk.client.session.get({ sessionID }, { throwOnError: true }) if (!result.data) { toast.show({