chore: merge v2 into service channel config
This commit is contained in:
commit
e76b29c0b4
1174 changed files with 21121 additions and 336917 deletions
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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")))),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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]],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]()),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue