chore: merge v2 into service channel config

This commit is contained in:
Dax Raad 2026-07-21 10:28:58 -04:00
commit e76b29c0b4
1174 changed files with 21121 additions and 336917 deletions

View file

@ -70,6 +70,7 @@ function testLayer(
watcher?: Layer.Layer<Watcher.Service>,
credentialNode = emptyCredentialNode,
wellknownNode = emptyWellknownNode,
options?: Config.Options,
) {
const locationLayer = Layer.succeed(
Location.Service,
@ -81,6 +82,7 @@ function testLayer(
),
)
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
[Config.node, Config.configured(options)],
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
[Credential.node, credentialNode],
@ -98,6 +100,90 @@ const provider = {
}
describe("Config", () => {
it.live("loads explicit file and content overrides in priority order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const explicit = path.join(tmp.path, "custom.json")
return Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
await fs.writeFile(explicit, JSON.stringify({ shell: "explicit" }))
await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const config = yield* Config.Service
const entries = yield* config.entries()
expect(
entries.flatMap((entry) =>
entry.type === "document" && entry.info.shell ? [entry.info.shell] : [],
),
).toEqual(["global", "explicit", "project", "content"])
expect(Config.latest(entries, "shell")).toBe("content")
}).pipe(
Effect.provide(
testLayer(
project,
global,
project,
undefined,
undefined,
emptyCredentialNode,
emptyWellknownNode,
{ file: explicit, content: JSON.stringify({ shell: "content" }) },
),
),
),
),
)
}),
),
)
it.live("skips project configuration when project discovery is disabled", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
return Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ shell: "global" }))
await fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell: "project" }))
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const config = yield* Config.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
}).pipe(
Effect.provide(
testLayer(
project,
global,
project,
undefined,
undefined,
emptyCredentialNode,
emptyWellknownNode,
{ project: false },
),
),
),
),
)
}),
),
)
it.live("reloads external config and publishes directory updates", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),

View file

@ -324,7 +324,7 @@ describe("DatabaseMigration", () => {
test("serializes concurrent embedded initialization for one database path", async () => {
await using tmp = await tmpdir()
const filename = path.join(tmp.path, "embedded.sqlite")
const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
const layers = [Database.layer({ path: filename }), Database.layer({ path: filename })]
await Effect.runPromise(
Effect.all(

View file

@ -8,14 +8,11 @@ import { fileLogger } from "@opencode-ai/util/observability/logging"
import { resource } from "@opencode-ai/util/observability/otlp"
const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
const opencodeClient = process.env.OPENCODE_CLIENT
afterEach(() => {
if (otelResourceAttributes === undefined) delete process.env.OTEL_RESOURCE_ATTRIBUTES
else process.env.OTEL_RESOURCE_ATTRIBUTES = otelResourceAttributes
if (opencodeClient === undefined) delete process.env.OPENCODE_CLIENT
else process.env.OPENCODE_CLIENT = opencodeClient
})
describe("resource", () => {
@ -39,16 +36,15 @@ describe("resource", () => {
})
test("keeps built-in attributes when env values conflict", () => {
process.env.OPENCODE_CLIENT = "cli"
process.env.OTEL_RESOURCE_ATTRIBUTES =
"opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
expect(resource().attributes).toMatchObject({
expect(resource("cli").attributes).toMatchObject({
"opencode.client": "cli",
"service.namespace": "anomalyco",
})
expect(resource().attributes["service.instance.id"]).not.toBe("override")
expect(resource().attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
expect(resource("cli").attributes["service.instance.id"]).not.toBe("override")
expect(resource("cli").attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
})
})
@ -66,7 +62,7 @@ test("falls back to local logging when OTLP initialization fails", async () => {
`
import { Effect } from "effect"
import { Observability } from "@opencode-ai/util/observability"
await Effect.void.pipe(Effect.provide(Observability.layer), Effect.scoped, Effect.runPromise)
await Effect.void.pipe(Effect.provide(Observability.layer()), Effect.scoped, Effect.runPromise)
`,
],
{

File diff suppressed because one or more lines are too long

View file

@ -20,8 +20,10 @@ const instructionLayer = (input: {
config: string
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
project?: boolean
}) =>
AppNodeBuilder.build(InstructionDiscovery.node, [
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[Global.node, Global.layerWith({ config: input.config })],
[Location.node, input.locationServiceLayer],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
@ -242,15 +244,14 @@ describe("InstructionDiscovery", () => {
it.effect("honors the project instruction opt-out", () =>
Effect.gen(function* () {
const previous = process.env.OPENCODE_DISABLE_PROJECT_CONFIG
let scanned = false
process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
config: "/global",
project: false,
filesystemLayer: Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
@ -263,12 +264,6 @@ describe("InstructionDiscovery", () => {
),
}),
),
Effect.ensuring(
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_DISABLE_PROJECT_CONFIG
else process.env.OPENCODE_DISABLE_PROJECT_CONFIG = previous
}),
),
)
expect(scanned).toBe(false)

View file

@ -1,11 +1,10 @@
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
import { describe, expect, beforeEach, afterAll } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Flag } from "@opencode-ai/util/flag"
import { Global } from "@opencode-ai/util/global"
import { ModelV2 } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
@ -14,22 +13,6 @@ import { it } from "./lib/effect"
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
// resolve providers without network. These tests need to drive the on-disk
// cache themselves and silence the eager refresh fork. Save/restore around
// the suite — never leak the mutation to subsequent test files in the same
// bun process.
const ORIGINAL_MODELS_PATH = Flag.OPENCODE_MODELS_PATH
const ORIGINAL_DISABLE_FETCH = Flag.OPENCODE_DISABLE_MODELS_FETCH
beforeAll(() => {
Flag.OPENCODE_MODELS_PATH = undefined
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
})
afterAll(() => {
Flag.OPENCODE_MODELS_PATH = ORIGINAL_MODELS_PATH
Flag.OPENCODE_DISABLE_MODELS_FETCH = ORIGINAL_DISABLE_FETCH
})
const cacheFile = path.join(Global.Path.cache, "models.json")
const fixture = {
@ -172,12 +155,13 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
}),
)
const buildLayer = (state: Ref.Ref<MockState>) =>
const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fetch: false }) =>
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run.
Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
]),
)
@ -243,17 +227,8 @@ describe("ModelsDev Service", () => {
Effect.gen(function* () {
yield* writeCacheText("{")
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const context = yield* Layer.build(buildLayer(state))
const result = yield* Effect.acquireUseRelease(
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = false
}),
() => ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)),
() =>
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
}),
)
const context = yield* Layer.build(buildLayer(state, { fetch: true }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2))
const final = yield* Ref.get(state)

View file

@ -1,10 +1,13 @@
import { describe, expect, test } from "bun:test"
import { Patch } from "@opencode-ai/core/patch"
import { Result } from "effect"
const parse = (input: string) => Result.getOrThrow(Patch.parse(input))
describe("Patch", () => {
test("parses add, update, and delete hunks", () => {
expect(
Patch.parse(
parse(
"*** Begin Patch\n*** Add File: add.txt\n+added\n*** Update File: update.txt\n@@ section\n-old\n+new\n*** Delete File: delete.txt\n*** End Patch",
),
).toEqual([
@ -19,18 +22,148 @@ describe("Patch", () => {
])
})
test("parses a file move", () => {
expect(
parse(
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch",
),
).toEqual([
{
type: "update",
path: "old.txt",
movePath: "new.txt",
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined, endOfFile: undefined }],
},
])
})
test("identifies the missing patch boundary", () => {
expect(() => parse("This is not a valid patch")).toThrow(
"The first line of the patch must be '*** Begin Patch'",
)
expect(() => parse("*** Begin Patch\n*** Add File: add.txt\n+added")).toThrow(
"The last line of the patch must be '*** End Patch'",
)
})
test("strips a heredoc wrapper", () => {
expect(Patch.parse("cat <<'EOF'\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
expect(parse("cat <<'EOF'\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
{ type: "add", path: "add.txt", contents: "added" },
])
})
test("strips a heredoc wrapper without cat", () => {
expect(parse("<<EOF\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
{ type: "add", path: "add.txt", contents: "added" },
])
})
test("parses a whitespace-padded hunk header", () => {
expect(parse("*** Begin Patch\n *** Update File: foo.txt\n@@\n-old\n+new\n*** End Patch")).toEqual([
{
type: "update",
path: "foo.txt",
movePath: undefined,
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined, endOfFile: undefined }],
},
])
})
test("parses leading and trailing whitespace around patch markers", () => {
expect(parse(" *** Begin Patch\n*** Update File: file.txt\n@@\n-one\n+two\n*** End Patch ")).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [{ oldLines: ["one"], newLines: ["two"], changeContext: undefined, endOfFile: undefined }],
},
])
})
test("parses whitespace on the inner sides of patch marker lines", () => {
expect(parse("*** Begin Patch \n*** Update File: file.txt\n@@\n-one\n+two\n *** End Patch")).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [{ oldLines: ["one"], newLines: ["two"], changeContext: undefined, endOfFile: undefined }],
},
])
})
test("strips one carriage return from CRLF patch lines", () => {
expect(parse("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\n+new\r\n*** End Patch\r\n")).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined, endOfFile: undefined }],
},
])
})
test("preserves an extra carriage return in CRLF patch lines", () => {
expect(parse("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\r\n+new\r\n*** End Patch\r\n")).toEqual([
{
type: "update",
path: "file.txt",
movePath: undefined,
chunks: [{ oldLines: ["old\r"], newLines: ["new"], changeContext: undefined, endOfFile: undefined }],
},
])
})
test("derives fuzzy line updates while preserving BOM", () => {
const update = Patch.derive("update.txt", [{ oldLines: [" old "], newLines: ["new"] }], "\uFEFFold\n")
expect(update).toEqual({ content: "new\n", bom: true })
expect(Patch.joinBom(update.content, update.bom)).toBe("\uFEFFnew\n")
})
test("derives multiple update chunks", () => {
expect(
Patch.derive(
"update.txt",
[
{ oldLines: ["line 2"], newLines: ["LINE 2"] },
{ oldLines: ["line 4"], newLines: ["LINE 4"] },
],
"line 1\nline 2\nline 3\nline 4\n",
).content,
).toBe("line 1\nLINE 2\nline 3\nLINE 4\n")
})
test("updates empty files and adds a trailing newline", () => {
expect(Patch.derive("empty.txt", [{ oldLines: [], newLines: ["First line"] }], "").content).toBe(
"First line\n",
)
expect(Patch.derive("no-newline.txt", [{ oldLines: ["old"], newLines: ["new"] }], "old").content).toBe(
"new\n",
)
})
test("disambiguates updates with change context", () => {
expect(
Patch.derive(
"update.txt",
[{ oldLines: ["x=10"], newLines: ["x=11"], changeContext: "fn b" }],
"fn a\nx=10\nfn b\nx=10\n",
).content,
).toBe("fn a\nx=10\nfn b\nx=11\n")
})
test("matches leading, trailing, and Unicode punctuation differences", () => {
expect(Patch.derive("leading.txt", [{ oldLines: ["line"], newLines: ["next"] }], " line\n").content).toBe(
"next\n",
)
expect(Patch.derive("trailing.txt", [{ oldLines: ["line"], newLines: ["next"] }], "line \n").content).toBe(
"next\n",
)
expect(
Patch.derive('unicode.txt', [{ oldLines: ['He said "hello"'], newLines: ['He said "hi"'] }], 'He said “hello”\n')
.content,
).toBe('He said "hi"\n')
})
test("matches EOF-anchored chunks from the end", () => {
expect(
Patch.derive(
@ -41,28 +174,15 @@ describe("Patch", () => {
).toBe("marker\nmiddle\nmarker changed\nend\n")
})
test("parses the EOF marker inside update chunks", () => {
expect(
Patch.parse("*** Begin Patch\n*** Update File: update.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"),
).toEqual([
{
type: "update",
path: "update.txt",
movePath: undefined,
chunks: [{ oldLines: ["last"], newLines: ["end"], changeContext: undefined, endOfFile: true }],
},
test("matches V1 lenient parsing of malformed hunk bodies", () => {
expect(parse("*** Begin Patch\n*** Add File: add.txt\nmissing plus\n*** End Patch")).toEqual([
{ type: "add", path: "add.txt", contents: "" },
])
expect(parse("*** Begin Patch\n*** Update File: update.txt\n*** End Patch")).toEqual([
{ type: "update", path: "update.txt", movePath: undefined, chunks: [] },
])
expect(parse("*** Begin Patch\n*** Delete File: delete.txt\nunexpected body\n*** End Patch")).toEqual([
{ type: "delete", path: "delete.txt" },
])
})
test("rejects malformed hunk bodies", () => {
expect(() => Patch.parse("*** Begin Patch\n*** Add File: add.txt\nmissing plus\n*** End Patch")).toThrow(
"Invalid add file line",
)
expect(() => Patch.parse("*** Begin Patch\n*** Update File: update.txt\n*** End Patch")).toThrow(
"expected at least one @@ chunk",
)
expect(() => Patch.parse("*** Begin Patch\n*** Delete File: delete.txt\nunexpected body\n*** End Patch")).toThrow(
"Invalid patch line",
)
})
})

View file

@ -7,7 +7,6 @@ import { Integration } from "@opencode-ai/core/integration"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Flag } from "@opencode-ai/util/flag"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
@ -26,6 +25,8 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
[Location.node, locationLayer],
])
const it = testEffect(layer)
const models = (file: string) =>
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
describe("ModelsDevPlugin", () => {
it.effect("projects normalized models.dev snapshots into the catalog", () =>
@ -193,410 +194,361 @@ describe("ModelsDevPlugin", () => {
)
it.effect("registers key methods for providers with environment variables", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
path: Flag.OPENCODE_MODELS_PATH,
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
}
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
return previous
}),
() =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
)
expect(yield* integrations.list()).toEqual([
Integration.Info.make({
id: Integration.ID.make("acme"),
name: "Acme",
methods: [
{ type: "key" },
{
type: "env",
names: ["ACME_API_KEY"],
},
],
connections: [],
}),
])
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) =>
Effect.sync(() => {
Flag.OPENCODE_MODELS_PATH = previous.path
Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
),
)
expect(yield* integrations.list()).toEqual([
Integration.Info.make({
id: Integration.ID.make("acme"),
name: "Acme",
methods: [
{ type: "key" },
{
type: "env",
names: ["ACME_API_KEY"],
},
],
connections: [],
}),
])
}).pipe(Effect.provide(models(path.join(import.meta.dir, "fixtures", "models-dev.json")))),
)
it.effect("converts reasoning options into settings variants", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
path: Flag.OPENCODE_MODELS_PATH,
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
}
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
return previous
}),
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
)
const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
expect(model?.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("low"),
settings: {
reasoningEffort: "low",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
settings: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
expect(mode).toMatchObject({
id: "gpt-reasoning-high",
name: "GPT Reasoning High",
headers: { "x-mode": "high" },
body: { service_tier: "priority" },
})
expect(mode?.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
const pro = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-pro"))
expect(pro).toMatchObject({
id: "gpt-reasoning-pro",
body: { reasoning: { mode: "pro" } },
})
const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
})
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("max"),
settings: { thinking: { type: "enabled", budgetTokens: 31999 } },
})
const anthropicEffortModel = yield* catalog.model.get(
ProviderV2.ID.anthropic,
ModelV2.ID.make("claude-opus-4.7"),
)
expect(anthropicEffortModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("low"),
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
},
])
const anthropicToggleModel = yield* catalog.model.get(
ProviderV2.ID.anthropic,
ModelV2.ID.make("claude-toggle"),
)
expect(anthropicToggleModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const opus45 = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-opus-4-5"))
expect(opus45?.variants).toEqual([
{ id: ModelV2.VariantID.make("low"), settings: { effort: "low" } },
{ id: ModelV2.VariantID.make("high"), settings: { effort: "high" } },
])
const grok = yield* catalog.model.get(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4.5"))
expect(grok?.variants).toEqual(
["low", "medium", "high"].map((id) => ({
id: ModelV2.VariantID.make(id),
settings: { reasoningEffort: id },
})),
)
const minimax = yield* catalog.model.get(ProviderV2.ID.make("opencode-go"), ModelV2.ID.make("minimax-m3"))
expect(minimax?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const toggle = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-only"))
expect(toggle?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{ id: ModelV2.VariantID.make("thinking"), settings: { enableThinking: true } },
])
const combined = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-budget"))
expect(combined?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gateway = yield* catalog.model.get(ProviderV2.ID.make("vercel"), ModelV2.ID.make("alibaba/qwen-toggle"))
expect(gateway?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gatewayNova = yield* catalog.model.get(
ProviderV2.ID.make("vercel"),
ModelV2.ID.make("amazon/nova-2-lite"),
)
expect(gatewayNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const gatewayFallback = yield* catalog.model.get(
ProviderV2.ID.make("vercel"),
ModelV2.ID.make("deepseek/deepseek-toggle"),
)
expect(gatewayFallback?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { reasoning: { enabled: false } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningEffort: "low" },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningEffort: "high" },
},
])
const openrouter = yield* catalog.model.get(
ProviderV2.ID.make("openrouter"),
ModelV2.ID.make("openrouter-toggle"),
)
expect(openrouter?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { reasoning: { enabled: false } } },
{ id: ModelV2.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } },
])
const google = yield* catalog.model.get(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini-2.5-flash"))
expect(google?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const vertex = yield* catalog.model.get(
ProviderV2.ID.make("google-vertex"),
ModelV2.ID.make("gemini-2.5-flash-lite"),
)
expect(vertex?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const bedrock = yield* catalog.model.get(
ProviderV2.ID.make("amazon-bedrock"),
ModelV2.ID.make("amazon.nova-2-lite-v1:0"),
)
expect(bedrock?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const sapGemini = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("gemini-2.5-flash"),
)
expect(sapGemini?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } },
},
])
const sapNova = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("amazon--nova-lite"),
)
expect(sapNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: {
modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } },
},
},
{
id: ModelV2.VariantID.make("low"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "low" } } },
},
},
{
id: ModelV2.VariantID.make("high"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "high" } } },
},
},
])
const sapCohere = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("cohere--command-a-reasoning"),
)
expect(sapCohere?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinking: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { modelParams: { reasoning_effort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { modelParams: { reasoning_effort: "high" } },
},
])
const sapAnthropicEffort = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4.7-opus"),
)
expect(sapAnthropicEffort?.variants).toEqual([
{
id: ModelV2.VariantID.make("low"),
settings: {
modelParams: {
additionalModelRequestFields: {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "low" },
},
},
},
},
])
const sapAnthropicBudget = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4-sonnet"),
)
expect(sapAnthropicBudget?.variants).toEqual([
{
id: ModelV2.VariantID.make("high"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 8000 } },
},
},
},
{
id: ModelV2.VariantID.make("max"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 16000 } },
},
},
},
])
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) =>
Effect.sync(() => {
Flag.OPENCODE_MODELS_PATH = previous.path
Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
),
)
const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
expect(model?.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("low"),
settings: {
reasoningEffort: "low",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
settings: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
expect(mode).toMatchObject({
id: "gpt-reasoning-high",
name: "GPT Reasoning High",
headers: { "x-mode": "high" },
body: { service_tier: "priority" },
})
expect(mode?.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
const pro = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-pro"))
expect(pro).toMatchObject({
id: "gpt-reasoning-pro",
body: { reasoning: { mode: "pro" } },
})
const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
})
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("max"),
settings: { thinking: { type: "enabled", budgetTokens: 31999 } },
})
const anthropicEffortModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-opus-4.7"))
expect(anthropicEffortModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("low"),
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
},
])
const anthropicToggleModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-toggle"))
expect(anthropicToggleModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const opus45 = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-opus-4-5"))
expect(opus45?.variants).toEqual([
{ id: ModelV2.VariantID.make("low"), settings: { effort: "low" } },
{ id: ModelV2.VariantID.make("high"), settings: { effort: "high" } },
])
const grok = yield* catalog.model.get(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4.5"))
expect(grok?.variants).toEqual(
["low", "medium", "high"].map((id) => ({
id: ModelV2.VariantID.make(id),
settings: { reasoningEffort: id },
})),
)
const minimax = yield* catalog.model.get(ProviderV2.ID.make("opencode-go"), ModelV2.ID.make("minimax-m3"))
expect(minimax?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const toggle = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-only"))
expect(toggle?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{ id: ModelV2.VariantID.make("thinking"), settings: { enableThinking: true } },
])
const combined = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-budget"))
expect(combined?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gateway = yield* catalog.model.get(ProviderV2.ID.make("vercel"), ModelV2.ID.make("alibaba/qwen-toggle"))
expect(gateway?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gatewayNova = yield* catalog.model.get(ProviderV2.ID.make("vercel"), ModelV2.ID.make("amazon/nova-2-lite"))
expect(gatewayNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const gatewayFallback = yield* catalog.model.get(
ProviderV2.ID.make("vercel"),
ModelV2.ID.make("deepseek/deepseek-toggle"),
)
expect(gatewayFallback?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { reasoning: { enabled: false } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningEffort: "low" },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningEffort: "high" },
},
])
const openrouter = yield* catalog.model.get(
ProviderV2.ID.make("openrouter"),
ModelV2.ID.make("openrouter-toggle"),
)
expect(openrouter?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { reasoning: { enabled: false } } },
{ id: ModelV2.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } },
])
const google = yield* catalog.model.get(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini-2.5-flash"))
expect(google?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const vertex = yield* catalog.model.get(
ProviderV2.ID.make("google-vertex"),
ModelV2.ID.make("gemini-2.5-flash-lite"),
)
expect(vertex?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const bedrock = yield* catalog.model.get(
ProviderV2.ID.make("amazon-bedrock"),
ModelV2.ID.make("amazon.nova-2-lite-v1:0"),
)
expect(bedrock?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const sapGemini = yield* catalog.model.get(ProviderV2.ID.make("sap-ai-core"), ModelV2.ID.make("gemini-2.5-flash"))
expect(sapGemini?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } },
},
])
const sapNova = yield* catalog.model.get(ProviderV2.ID.make("sap-ai-core"), ModelV2.ID.make("amazon--nova-lite"))
expect(sapNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: {
modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } },
},
},
{
id: ModelV2.VariantID.make("low"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "low" } } },
},
},
{
id: ModelV2.VariantID.make("high"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "high" } } },
},
},
])
const sapCohere = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("cohere--command-a-reasoning"),
)
expect(sapCohere?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinking: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { modelParams: { reasoning_effort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { modelParams: { reasoning_effort: "high" } },
},
])
const sapAnthropicEffort = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4.7-opus"),
)
expect(sapAnthropicEffort?.variants).toEqual([
{
id: ModelV2.VariantID.make("low"),
settings: {
modelParams: {
additionalModelRequestFields: {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "low" },
},
},
},
},
])
const sapAnthropicBudget = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4-sonnet"),
)
expect(sapAnthropicBudget?.variants).toEqual([
{
id: ModelV2.VariantID.make("high"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 8000 } },
},
},
},
{
id: ModelV2.VariantID.make("max"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 16000 } },
},
},
},
])
}).pipe(Effect.provide(models(path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")))),
)
})

View file

@ -56,7 +56,7 @@ const locations = Layer.effect(
() =>
// The test only needs the compaction location service used by SessionV2.compact.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
SessionCompaction.layer.pipe(
SessionCompaction.layer().pipe(
Layer.provide(client),
Layer.provide(config),
Layer.provide(models),

View file

@ -18,7 +18,6 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Flag } from "@opencode-ai/util/flag"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
@ -190,7 +189,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
"User-Agent": `opencode/${InstallationVersion}`,
"x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID,
"x-opencode-client": Flag.OPENCODE_CLIENT,
"x-opencode-client": "cli",
})
expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")

View file

@ -460,7 +460,7 @@ describe("SessionV2.create", () => {
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite"))
const targetDatabase = Database.layer({ path: path.join(tmp.path, "target.sqlite") })
const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node]),
[[Database.node, targetDatabase]],

View file

@ -78,6 +78,25 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("treats an empty configured API key as omitted", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
}),
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://openai.example/v1/responses",
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBeUndefined()
}),
)
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
@ -637,6 +656,29 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("drops an empty API key before loading an AISDK package", () =>
Effect.gen(function* () {
const native = yield* SessionRunnerModel.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
yield* SessionRunnerModel.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/google"), {
settings: { apiKey: "", baseURL: "https://google.example/v1" },
}),
undefined,
{
loadAISDK: (runtime) =>
Effect.sync(() => {
expect(runtime.settings).not.toHaveProperty("apiKey")
return native
}),
},
)
}),
)
it.effect("reports whether a catalog model declares a provider package", () =>
Effect.sync(() => {
expect(SessionRunnerModel.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true)

View file

@ -22,7 +22,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Flag } from "@opencode-ai/util/flag"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { EventTable } from "@opencode-ai/core/event/sql"
@ -3183,7 +3182,7 @@ describe("SessionRunnerLLM", () => {
"User-Agent": `opencode/${InstallationVersion}`,
"x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID,
"x-opencode-client": Flag.OPENCODE_CLIENT,
"x-opencode-client": "cli",
})
}),
)

View file

@ -17,7 +17,6 @@ import { SessionTitle } from "@opencode-ai/core/session/title"
import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Flag } from "@opencode-ai/util/flag"
import { InstallationVersion } from "@opencode-ai/util/installation/version"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
@ -153,7 +152,7 @@ it.effect("generates a title from the sole user message and renames the session"
"User-Agent": `opencode/${InstallationVersion}`,
"x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID,
"x-opencode-client": Flag.OPENCODE_CLIENT,
"x-opencode-client": "cli",
})
expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
const renamed = yield* store.get(sessionID)

View file

@ -23,13 +23,7 @@ describe("Snapshot", () => {
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
await initGit(project)
})
const git = yield* Git.Service.pipe(Effect.provide(AppNodeBuilder.build(Git.node)))
@ -99,13 +93,7 @@ describe("Snapshot", () => {
await fs.mkdir(location, { recursive: true })
await fs.writeFile(path.join(location, "tracked.txt"), "one\n")
await fs.writeFile(path.join(project, "outside.txt"), "outside\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
await initGit(project)
})
const layer = snapshotLayer(tmp.path, location)
@ -168,14 +156,8 @@ describe("Snapshot", () => {
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "main\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
await $`git worktree add --detach ${linked} HEAD`.cwd(project).quiet()
await initGit(project, true)
await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet()
})
const capture = (directory: string) =>
@ -213,13 +195,7 @@ describe("Snapshot", () => {
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
await $`git init`.cwd(project).quiet()
await $`git config core.fsmonitor false`.cwd(project).quiet()
await $`git config commit.gpgsign false`.cwd(project).quiet()
await $`git config user.email test@opencode.test`.cwd(project).quiet()
await $`git config user.name Test`.cwd(project).quiet()
await $`git add .`.cwd(project).quiet()
await $`git commit -m initial`.cwd(project).quiet()
await initGit(project)
})
yield* Effect.gen(function* () {
@ -251,3 +227,12 @@ function snapshotLayer(data: string, directory: string) {
function read(file: string) {
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replaceAll("\r\n", "\n")))
}
async function initGit(directory: string, commit = false) {
await $`git init`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
if (!commit) return
await $`git -c user.email=test@opencode.test -c user.name=Test commit --no-gpg-sign -m initial`
.cwd(directory)
.quiet()
}

View file

@ -1,13 +1,11 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Effect, Exit, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
@ -23,7 +21,7 @@ import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefiniti
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
deps: [ToolRegistry.toolsNode, FSUtil.node, Location.node, PermissionV2.node],
})
const sessionID = SessionV2.ID.make("ses_patch_tool_test")
@ -32,9 +30,6 @@ let denyAction: string | undefined
let failRemoveTarget: string | undefined
let readsBeforeEditApproval = 0
let editApproved = false
let blockRemoveTarget: string | undefined
let removeStarted: Deferred.Deferred<void> | undefined
let releaseRemove: Deferred.Deferred<void> | undefined
let afterEditApproval = (): Effect.Effect<void> => Effect.void
const permission = Layer.succeed(
@ -72,9 +67,6 @@ const reset = () => {
failRemoveTarget = undefined
readsBeforeEditApproval = 0
editApproved = false
blockRemoveTarget = undefined
removeStarted = undefined
releaseRemove = undefined
afterEditApproval = () => Effect.void
}
@ -90,21 +82,25 @@ const filesystem = Layer.effect(
}).pipe(Effect.andThen(fs.readFile(target))),
remove: (target, options) => {
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
if (blockRemoveTarget && path.basename(target) === blockRemoveTarget && removeStarted && releaseRemove)
return Deferred.succeed(removeStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseRemove)),
Effect.andThen(fs.remove(target, options)),
)
return fs.remove(target, options)
},
})
}),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
const withTool = <A, E, R>(
directory: string,
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
projectDirectory = directory,
) => {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory) },
),
),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
@ -114,8 +110,6 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
LayerNode.group([
ToolRegistry.node,
ToolRegistry.toolsNode,
LocationMutation.node,
FileMutation.node,
patchToolNode,
]),
[
@ -143,6 +137,15 @@ const exists = (target: string) =>
),
)
const it = testEffect(Layer.empty)
const withTempTool = <A, E, R>(body: (directory: string, registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withTool(tmp.path, (registry) => body(tmp.path, registry))
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
describe("PatchTool", () => {
it.live("registers and sequentially applies add, update, and delete hunks", () =>
@ -167,8 +170,9 @@ describe("PatchTool", () => {
)
expect(settled.result).toEqual({
type: "text",
value: "Applied patch sequentially:\nA nested/new.txt\nM update.txt\nD remove.txt",
value: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt",
})
if (process.platform === "win32") expect(settled.result.value).not.toContain("\\")
expect(settled.output?.structured).toMatchObject({
applied: [
{ type: "add", resource: "nested/new.txt" },
@ -194,15 +198,25 @@ describe("PatchTool", () => {
file: "remove.txt",
status: "deleted",
additions: 0,
deletions: 1,
deletions: 2,
patch: expect.stringContaining("-remove"),
},
],
})
expect(assertions).toMatchObject([
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
{
sessionID,
action: "edit",
resources: ["nested/new.txt", "update.txt", "remove.txt"],
save: ["*"],
metadata: {
filepath: "nested/new.txt, update.txt, remove.txt",
diff: expect.stringContaining("Index:"),
files: expect.any(Array),
},
},
])
expect(readsBeforeEditApproval).toBe(0)
expect(readsBeforeEditApproval).toBe(2)
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "nested/new.txt"), "utf8"))).toBe(
"created\n",
)
@ -217,7 +231,7 @@ describe("PatchTool", () => {
),
)
it.live("rejects moves before applying any hunk", () =>
it.live("moves and updates a file", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@ -234,9 +248,17 @@ describe("PatchTool", () => {
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toEqual({ type: "error", value: "patch moves are not supported yet" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
expect(assertions).toEqual([])
).toEqual({
type: "text",
value: "Success. Updated the following files:\nA created.txt\nM moved.txt",
})
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "moved.txt"), "utf8"))).toBe(
"after\n",
)
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "created.txt"), "utf8"))).toBe(
"created\n",
)
}),
),
),
@ -246,7 +268,427 @@ describe("PatchTool", () => {
),
)
it.live("approves an external directory and the batch before reading external update content", () =>
it.live("moves a file over an existing destination", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const source = path.join(tmp.path, "old.txt")
const destination = path.join(tmp.path, "nested", "moved.txt")
return Effect.promise(() =>
Promise.all([
fs.writeFile(source, "before\n"),
fs.mkdir(path.dirname(destination), { recursive: true }).then(() => fs.writeFile(destination, "existing\n")),
]),
).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: nested/moved.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toMatchObject({ type: "text" })
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n")
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("moves a symlink without deleting its target", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
if (process.platform === "win32") return
const target = path.join(directory, "target.txt")
const source = path.join(directory, "link.txt")
const moved = path.join(directory, "moved.txt")
yield* Effect.promise(() => fs.writeFile(target, "before\n"))
yield* Effect.promise(() => fs.symlink(target, source))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: link.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
)
expect(yield* exists(source)).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
expect(yield* Effect.promise(() => fs.readFile(moved, "utf8"))).toBe("after\n")
}),
),
)
it.live("includes move file info in structured output", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const source = path.join(directory, "old", "name.txt")
yield* Effect.promise(() => fs.mkdir(path.dirname(source), { recursive: true }))
yield* Effect.promise(() => fs.writeFile(source, "old content\n"))
const settled = yield* settleTool(
registry,
call(
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch",
),
)
expect(settled.output?.structured).toMatchObject({
applied: [{ type: "update", resource: "renamed/dir/name.txt" }],
files: [
{
file: "renamed/dir/name.txt",
status: "modified",
patch: expect.stringContaining("-old content\n+new content"),
},
],
})
}),
),
)
it.live("inserts lines with an insert-only hunk", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "insert-only.txt")
yield* Effect.promise(() => fs.writeFile(target, "alpha\nomega\n"))
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: insert-only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("alpha\nbeta\nomega\n")
}),
),
)
it.live("updates an empty file", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "empty.txt")
yield* Effect.promise(() => fs.writeFile(target, ""))
yield* executeTool(registry, call("*** Begin Patch\n*** Update File: empty.txt\n@@\n+First line\n*** End Patch"))
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("First line\n")
}),
),
)
it.live("rejects deleting a directory", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "dir")))
expect(
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: dir\n*** End Patch")),
).toMatchObject({ type: "error" })
expect(yield* exists(path.join(directory, "dir"))).toBe(true)
}),
),
)
it.live("supports an end-of-file anchor", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "tail.txt")
yield* Effect.promise(() => fs.writeFile(target, "alpha\nlast\n"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch",
),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("alpha\nend\n")
}),
),
)
it.live("rejects a missing second chunk context", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "two-chunks.txt")
yield* Effect.promise(() => fs.writeFile(target, "a\nb\nc\nd\n"))
expect(
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch",
),
),
).toMatchObject({ type: "error" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nb\nc\nd\n")
}),
),
)
it.live("requires patchText", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call(""))).toEqual({ type: "error", value: "patchText is required" })
}),
),
)
it.live("rejects invalid patch format", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call("invalid patch"))).toEqual({
type: "error",
value: "patch verification failed: The first line of the patch must be '*** Begin Patch'",
})
expect(yield* executeTool(registry, call("*** Begin Patch\n*** Add File: foo\n+hello"))).toEqual({
type: "error",
value: "patch verification failed: The last line of the patch must be '*** End Patch'",
})
}),
),
)
it.live("rejects an empty patch", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call("*** Begin Patch\n*** End Patch"))).toEqual({
type: "error",
value: "patch rejected: empty patch",
})
}),
),
)
it.live("rejects an invalid hunk header", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"),
),
).toEqual({
type: "error",
value:
"patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'",
})
}),
),
)
it.live("applies multiple hunks to one file", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "multi.txt")
yield* Effect.promise(() => fs.writeFile(target, "a\nb\nc\nd\n"))
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: multi.txt\n@@\n-b\n+B\n@@\n-d\n+D\n*** End Patch"),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nB\nc\nD\n")
}),
),
)
it.live("applies successive update operations to one file", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "successive.txt")
yield* Effect.promise(() => fs.writeFile(target, "a\nb\n"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: successive.txt\n@@\n-a\n+A\n*** Update File: successive.txt\n@@\n-b\n+B\n*** End Patch",
),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("A\nB\n")
}),
),
)
it.live("does not invent a first-line diff for BOM files", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const bom = "\uFEFF"
const target = path.join(directory, "example.cs")
yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`))
const settled = yield* settleTool(
registry,
call(
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch",
),
)
const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output?.structured)
expect(output.files[0]?.patch).not.toContain(bom)
expect(output.files[0]?.patch).not.toContain("-using System;")
expect(output.files[0]?.patch).not.toContain("+using System;")
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(
`${bom}using System;\n\nclass Test {}\nclass Next {}\n`,
)
}),
),
)
it.live("appends a trailing newline on update", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "no-newline.txt")
yield* Effect.promise(() => fs.writeFile(target, "no newline at end"))
yield* executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: no-newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch",
),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("first line\nsecond line\n")
}),
),
)
it.live("disambiguates change context with an @@ header", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "context.txt")
yield* Effect.promise(() => fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n"))
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: context.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(
"fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n",
)
}),
),
)
it.live("parses a heredoc-wrapped patch", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
yield* executeTool(
registry,
call("cat <<'EOF'\n*** Begin Patch\n*** Add File: heredoc.txt\n+with cat\n*** End Patch\nEOF"),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "heredoc.txt"), "utf8"))).toBe(
"with cat\n",
)
}),
),
)
it.live("parses a heredoc-wrapped patch without cat", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
yield* executeTool(
registry,
call("<<EOF\n*** Begin Patch\n*** Add File: heredoc.txt\n+without cat\n*** End Patch\nEOF"),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "heredoc.txt"), "utf8"))).toBe(
"without cat\n",
)
}),
),
)
it.live("matches with trailing whitespace differences", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "trailing.txt")
yield* Effect.promise(() => fs.writeFile(target, "line1 \nline2\nline3 \n"))
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: trailing.txt\n@@\n-line2\n+changed\n*** End Patch"),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1 \nchanged\nline3 \n")
}),
),
)
it.live("matches with leading whitespace differences", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "leading.txt")
yield* Effect.promise(() => fs.writeFile(target, " line1\nline2\n line3\n"))
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: leading.txt\n@@\n-line2\n+changed\n*** End Patch"),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(" line1\nchanged\n line3\n")
}),
),
)
it.live("matches with Unicode punctuation differences", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "unicode.txt")
yield* Effect.promise(() => fs.writeFile(target, "He said “hello”\nsome—dash\nend\n"))
yield* executeTool(
registry,
call(
'*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch',
),
)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe('He said "hi"\nsome—dash\nend\n')
}),
),
)
it.live("rejects an update with missing context", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
const target = path.join(directory, "unchanged.txt")
yield* Effect.promise(() => fs.writeFile(target, "line1\nline2\n"))
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: unchanged.txt\n@@\n-missing\n+changed\n*** End Patch"),
),
).toMatchObject({ type: "error", value: expect.stringContaining("Failed to find expected lines") })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1\nline2\n")
}),
),
)
it.live("rejects an update when the target file is missing", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"),
),
).toMatchObject({
type: "error",
value: expect.stringContaining(
`patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `,
),
})
}),
),
)
it.live("identifies a directory used as an update target", () =>
withTempTool((directory, registry) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "nested")))
expect(
yield* executeTool(registry, call("*** Begin Patch\n*** Update File: nested\n@@\n-old\n+new\n*** End Patch")),
).toEqual({
type: "error",
value: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`,
})
}),
),
)
it.live("rejects a delete when the target file is missing", () =>
withTempTool((_directory, registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch")),
).toMatchObject({ type: "error", value: expect.stringContaining("patch verification failed") })
}),
),
)
it.live("approves an external directory before reading and requests edit permission afterward", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
@ -263,7 +705,7 @@ describe("PatchTool", () => {
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(readsBeforeEditApproval).toBe(0)
expect(readsBeforeEditApproval).toBe(1)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
),
@ -277,7 +719,106 @@ describe("PatchTool", () => {
),
)
it.live("approves a relative external target before reading update content", () =>
it.live("does not inspect an external file when external permission is denied", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "external.txt")
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(
active.path,
(registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
),
).toMatchObject({ type: "error" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
expect(readsBeforeEditApproval).toBe(0)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
}),
path.parse(active.path).root,
),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("treats a sibling path inside the project worktree as internal", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const active = path.join(tmp.path, "active")
const target = path.join(tmp.path, "sibling.txt")
return Effect.promise(() => Promise.all([fs.mkdir(active), fs.writeFile(target, "before\n")])).pipe(
Effect.andThen(
withTool(
active,
(registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
tmp.path,
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("follows an internal symlink to an external file without external permission", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
if (process.platform === "win32") return Effect.void
const target = path.join(outside.path, "external.txt")
const link = path.join(active.path, "link.txt")
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(Effect.promise(() => fs.symlink(target, link))),
Effect.andThen(
withTool(active.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(
registry,
call("*** Begin Patch\n*** Update File: link.txt\n@@\n-before\n+after\n*** End Patch"),
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("approves a relative external target before reading and requests edit permission afterward", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
@ -295,7 +836,7 @@ describe("PatchTool", () => {
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(readsBeforeEditApproval).toBe(0)
expect(readsBeforeEditApproval).toBe(1)
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
),
@ -309,7 +850,7 @@ describe("PatchTool", () => {
),
)
it.live("approves one external directory scope for multiple files under the same parent", () =>
it.live("approves each external file under the same parent", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
@ -330,10 +871,17 @@ describe("PatchTool", () => {
),
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions[0]?.resources).toEqual([
path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
expect(assertions.map((input) => input.action)).toEqual([
"external_directory",
"external_directory",
"edit",
])
expect(assertions[0]?.resources).toEqual([
process.platform === "win32"
? FSUtil.normalizePathPattern(path.join(outside.path, "*"))
: path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"),
])
expect(assertions[1]?.resources).toEqual(assertions[0]?.resources)
}),
),
),
@ -360,7 +908,10 @@ describe("PatchTool", () => {
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch",
),
),
).toEqual({ type: "error", value: "Unable to apply patch at missing.txt" })
).toMatchObject({
type: "error",
value: expect.stringContaining("patch verification failed: Failed to read file to update"),
})
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
}),
)
@ -369,7 +920,7 @@ describe("PatchTool", () => {
),
)
it.live("rejects add hunks targeting an existing file without replacing it", () =>
it.live("adds files by overwriting existing targets", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@ -384,8 +935,8 @@ describe("PatchTool", () => {
registry,
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
),
).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("sentinel\n")
).toMatchObject({ type: "text" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
}),
),
),
@ -395,7 +946,7 @@ describe("PatchTool", () => {
),
)
it.live("rejects an add target that appears during permission approval", () =>
it.live("overwrites an add target that appears during permission approval", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@ -409,8 +960,8 @@ describe("PatchTool", () => {
registry,
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
),
).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n")
).toMatchObject({ type: "text" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
}),
)
},
@ -449,35 +1000,4 @@ describe("PatchTool", () => {
),
)
it.live("finishes the sequential commit phase when interrupted after the first mutation", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const first = path.join(tmp.path, "first.txt")
const second = path.join(tmp.path, "second.txt")
blockRemoveTarget = path.basename(second)
return Effect.gen(function* () {
removeStarted = yield* Deferred.make<void>()
releaseRemove = yield* Deferred.make<void>()
yield* Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")]))
yield* withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const run = yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
).pipe(Effect.forkChild)
yield* Deferred.await(removeStarted!)
const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild)
yield* Deferred.succeed(releaseRemove!, undefined)
yield* Fiber.join(interrupt)
expect(yield* exists(first)).toBe(false)
expect(yield* exists(second)).toBe(false)
}),
)
})
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
})