chore: merge dev
This commit is contained in:
commit
c830f67ec5
860 changed files with 60540 additions and 23213 deletions
|
|
@ -1,6 +1,10 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Scope } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AgentV2.locationLayer)
|
||||
|
|
@ -98,4 +102,30 @@ describe("AgentV2", () => {
|
|||
expect(yield* agent.get(id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not ambiently opt built-in agents into bash", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
yield* AgentPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||
),
|
||||
)
|
||||
|
||||
const agents = yield* agent.all()
|
||||
expect(agents.map((item) => String(item.id)).sort()).toEqual([
|
||||
"build",
|
||||
"compaction",
|
||||
"explore",
|
||||
"general",
|
||||
"plan",
|
||||
"summary",
|
||||
"title",
|
||||
])
|
||||
for (const item of agents) {
|
||||
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
103
packages/core/test/background-job.test.ts
Normal file
103
packages/core/test/background-job.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { BackgroundJob } from "@opencode-ai/core/background-job"
|
||||
import { Deferred, Effect, Exit, Scope } from "effect"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
describe("BackgroundJob", () => {
|
||||
it.live("tracks process-local work through explicit observation", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* BackgroundJob.Service
|
||||
const latch = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
type: "test",
|
||||
metadata: { durable: false },
|
||||
run: Deferred.await(latch).pipe(Effect.as("done")),
|
||||
})
|
||||
|
||||
expect(job).toMatchObject({ type: "test", status: "running", metadata: { durable: false } })
|
||||
expect(yield* jobs.wait({ id: job.id, timeout: 0 })).toMatchObject({
|
||||
timedOut: true,
|
||||
info: { status: "running" },
|
||||
})
|
||||
|
||||
yield* Deferred.succeed(latch, undefined)
|
||||
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
|
||||
timedOut: false,
|
||||
info: { status: "completed", output: "done" },
|
||||
})
|
||||
}).pipe(Effect.provide(BackgroundJob.layer)),
|
||||
)
|
||||
|
||||
it.live("publishes jobs before starting immediately settling work", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* BackgroundJob.Service
|
||||
|
||||
yield* Effect.forEach(Array.from({ length: 100 }), (_, index) => {
|
||||
const id = `job_immediate_start_${index}`
|
||||
return Effect.gen(function* () {
|
||||
const job = yield* jobs.start({
|
||||
id,
|
||||
type: "test",
|
||||
run: jobs
|
||||
.get(id)
|
||||
.pipe(
|
||||
Effect.flatMap((info) =>
|
||||
info?.status === "running"
|
||||
? Effect.succeed(`done-${index}`)
|
||||
: Effect.fail("job started before publish"),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
|
||||
timedOut: false,
|
||||
info: { status: "completed", output: `done-${index}` },
|
||||
})
|
||||
})
|
||||
})
|
||||
}).pipe(Effect.provide(BackgroundJob.layer)),
|
||||
)
|
||||
|
||||
it.live("increments pending work before starting immediately settling extensions", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* BackgroundJob.Service
|
||||
|
||||
yield* Effect.forEach(Array.from({ length: 100 }), (_, index) =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
type: "test",
|
||||
run: Deferred.await(first).pipe(Effect.as(`first-${index}`)),
|
||||
})
|
||||
|
||||
expect(yield* jobs.extend({ id: job.id, run: Effect.succeed(`second-${index}`) })).toBe(true)
|
||||
expect((yield* jobs.get(job.id))?.status).toBe("running")
|
||||
|
||||
yield* Deferred.succeed(first, undefined)
|
||||
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
|
||||
timedOut: false,
|
||||
info: { status: "completed", output: `second-${index}` },
|
||||
})
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(BackgroundJob.layer)),
|
||||
)
|
||||
|
||||
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.make()
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const jobs = yield* BackgroundJob.make.pipe(Scope.provide(scope))
|
||||
const job = yield* jobs.start({
|
||||
type: "test",
|
||||
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
|
||||
})
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
|
||||
// The abandoned in-memory registry is not a durable observation channel.
|
||||
expect((yield* jobs.get(job.id))?.status).toBe("running")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -6,6 +6,7 @@ import { Location } from "@opencode-ai/core/location"
|
|||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
|
|
@ -20,7 +21,7 @@ const it = testEffect(
|
|||
)
|
||||
|
||||
describe("CatalogV2", () => {
|
||||
it.effect("normalizes provider baseURL into endpoint url", () =>
|
||||
it.effect("normalizes provider baseURL into api url", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
|
|
@ -28,16 +29,16 @@ describe("CatalogV2", () => {
|
|||
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://default.example.com",
|
||||
}
|
||||
provider.options.aisdk.provider.baseURL = "https://override.example.com"
|
||||
provider.request.body.baseURL = "https://override.example.com"
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* catalog.provider.get(providerID)).endpoint).toEqual({
|
||||
expect((yield* catalog.provider.get(providerID)).api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://override.example.com",
|
||||
|
|
@ -45,7 +46,7 @@ describe("CatalogV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes model baseURL into endpoint url", () =>
|
||||
it.effect("normalizes model baseURL into api url", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
|
|
@ -54,27 +55,34 @@ describe("CatalogV2", () => {
|
|||
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://provider.example.com",
|
||||
}
|
||||
})
|
||||
catalog.model.update(providerID, modelID, (model) => {
|
||||
model.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://model.example.com" }
|
||||
model.options.aisdk.provider.baseURL = "https://override.example.com"
|
||||
model.api = {
|
||||
id: modelID,
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://model.example.com",
|
||||
}
|
||||
model.request.body.baseURL = "https://override.example.com"
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({
|
||||
expect((yield* catalog.model.get(providerID, modelID)).api).toEqual({
|
||||
id: modelID,
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://override.example.com",
|
||||
settings: {},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves unknown model endpoint from provider endpoint", () =>
|
||||
it.effect("resolves default model api from provider api", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
|
|
@ -83,7 +91,7 @@ describe("CatalogV2", () => {
|
|||
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://provider.example.com",
|
||||
|
|
@ -92,7 +100,8 @@ describe("CatalogV2", () => {
|
|||
catalog.model.update(providerID, modelID, () => {})
|
||||
})
|
||||
|
||||
expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({
|
||||
expect((yield* catalog.model.get(providerID, modelID)).api).toEqual({
|
||||
id: modelID,
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://provider.example.com",
|
||||
|
|
@ -115,16 +124,16 @@ describe("CatalogV2", () => {
|
|||
Effect.sync(() => {
|
||||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
seen.push(item.provider.endpoint.type)
|
||||
if (item?.provider.endpoint.type === "aisdk") seen.push(item.provider.endpoint.url)
|
||||
seen.push(item?.provider.options.aisdk.provider.baseURL)
|
||||
seen.push(item.provider.api.type)
|
||||
if (item?.provider.api.type === "aisdk") seen.push(item.provider.api.url)
|
||||
seen.push(item?.provider.request.body.baseURL)
|
||||
}),
|
||||
}),
|
||||
})
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
provider.options.aisdk.provider.baseURL = "https://provider.example.com"
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
provider.request.body.baseURL = "https://provider.example.com"
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -179,7 +188,12 @@ describe("CatalogV2", () => {
|
|||
yield* events.publish(
|
||||
PluginV2.Event.Added,
|
||||
{ id: PluginV2.ID.make("test-transform") },
|
||||
{ location: { directory: AbsolutePath.make("other") } },
|
||||
{
|
||||
location: new Location.Info({
|
||||
directory: AbsolutePath.make("other"),
|
||||
project: { id: Project.ID.global, directory: AbsolutePath.make("other") },
|
||||
}),
|
||||
},
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
|
|
@ -187,7 +201,7 @@ describe("CatalogV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves provider and model option merges", () =>
|
||||
it.effect("resolves provider and model request merges", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
|
|
@ -196,25 +210,21 @@ describe("CatalogV2", () => {
|
|||
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.options.headers.provider = "provider"
|
||||
provider.options.headers.shared = "provider"
|
||||
provider.options.body.provider = true
|
||||
provider.options.aisdk.provider.provider = true
|
||||
provider.request.headers.provider = "provider"
|
||||
provider.request.headers.shared = "provider"
|
||||
provider.request.body.provider = true
|
||||
})
|
||||
catalog.model.update(providerID, modelID, (model) => {
|
||||
model.options.headers.model = "model"
|
||||
model.options.headers.shared = "model"
|
||||
model.options.body.model = true
|
||||
model.options.aisdk.provider.model = true
|
||||
model.options.aisdk.request.request = true
|
||||
model.request.headers.model = "model"
|
||||
model.request.headers.shared = "model"
|
||||
model.request.body.model = true
|
||||
model.request.body.request = true
|
||||
})
|
||||
})
|
||||
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
expect(model.options.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
|
||||
expect(model.options.body).toEqual({ provider: true, model: true })
|
||||
expect(model.options.aisdk.provider).toEqual({ provider: true, model: true })
|
||||
expect(model.options.aisdk.request).toEqual({ request: true })
|
||||
expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
|
||||
expect(model.request.body).toEqual({ provider: true, model: true, request: true })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
56
packages/core/test/command.test.ts
Normal file
56
packages/core/test/command.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(CommandV2.locationLayer)
|
||||
|
||||
describe("CommandV2", () => {
|
||||
it.effect("applies command transforms and preserves later overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* CommandV2.Service
|
||||
const transform = yield* command.transform()
|
||||
yield* transform((editor) => {
|
||||
editor.update("review", (command) => {
|
||||
command.template = "First"
|
||||
command.description = "Review code"
|
||||
})
|
||||
editor.update("review", (command) => {
|
||||
command.template = "Second"
|
||||
command.model = {
|
||||
id: ModelV2.ID.make("claude"),
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(yield* command.get("review")).toEqual(
|
||||
new CommandV2.Info({
|
||||
name: "review",
|
||||
template: "Second",
|
||||
description: "Review code",
|
||||
model: {
|
||||
id: ModelV2.ID.make("claude"),
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(yield* command.list()).toEqual([
|
||||
new CommandV2.Info({
|
||||
name: "review",
|
||||
template: "Second",
|
||||
description: "Review code",
|
||||
model: {
|
||||
id: ModelV2.ID.make("claude"),
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
},
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,21 +1,21 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, AppFileSystem.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer))
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("ConfigAgentPlugin.Plugin", () => {
|
||||
it.effect("applies global permissions between built-in and agent-specific permissions", () =>
|
||||
it.effect("applies all global permissions before agent-specific permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const build = AgentV2.ID.make("build")
|
||||
|
|
@ -29,11 +29,10 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
)
|
||||
|
||||
const config = Config.Service.of({
|
||||
directories: () => Effect.succeed([]),
|
||||
get: () =>
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
permissions: [{ action: "bash", resource: "*", effect: "ask" }],
|
||||
agents: {
|
||||
|
|
@ -44,18 +43,25 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
model: "openrouter/openai/gpt-5",
|
||||
description: "Review changes",
|
||||
mode: "subagent",
|
||||
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
|
||||
permissions: [
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "deny" },
|
||||
],
|
||||
},
|
||||
removed: { description: "Removed later" },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
||||
agents: {
|
||||
reviewer: { variant: "high", hidden: true },
|
||||
removed: { disabled: true },
|
||||
late: {
|
||||
permissions: [{ action: "edit", resource: "*", effect: "allow" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
|
@ -72,6 +78,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
expect(buildAgent.permissions).toEqual([
|
||||
{ action: "bash", resource: "*", effect: "allow" },
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "bash", resource: "git *", effect: "allow" },
|
||||
])
|
||||
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
|
||||
|
|
@ -87,7 +94,15 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
})
|
||||
expect(reviewer.permissions).toEqual([
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "deny" },
|
||||
])
|
||||
expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
|
||||
expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "allow" },
|
||||
])
|
||||
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
|
||||
}),
|
||||
|
|
@ -97,11 +112,10 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const config = Config.Service.of({
|
||||
directories: () => Effect.succeed([]),
|
||||
get: () =>
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
agents: {
|
||||
reviewer: {
|
||||
|
|
@ -112,24 +126,22 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
hidden: true,
|
||||
color: "warning",
|
||||
steps: 12,
|
||||
options: {
|
||||
request: {
|
||||
headers: { first: "one", shared: "first" },
|
||||
body: { enabled: true },
|
||||
aisdk: { provider: { profile: "review" }, request: { effort: "medium" } },
|
||||
body: { enabled: true, profile: "review", effort: "medium" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
agents: {
|
||||
reviewer: {
|
||||
options: {
|
||||
request: {
|
||||
headers: { shared: "last", second: "two" },
|
||||
body: { retries: 2 },
|
||||
aisdk: { request: { effort: "high" } },
|
||||
body: { retries: 2, effort: "high" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -154,10 +166,9 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
steps: 12,
|
||||
model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined },
|
||||
})
|
||||
expect(reviewer.options).toEqual({
|
||||
expect(reviewer.request).toEqual({
|
||||
headers: { first: "one", shared: "last", second: "two" },
|
||||
body: { enabled: true, retries: 2 },
|
||||
aisdk: { provider: { profile: "review" }, request: { effort: "high" } },
|
||||
body: { enabled: true, profile: "review", retries: 2, effort: "high" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -170,11 +181,10 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
yield* defaults((editor) => editor.update(build, () => {}))
|
||||
|
||||
const config = Config.Service.of({
|
||||
directories: () => Effect.succeed([]),
|
||||
get: () =>
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({ agents: { build: { disabled: true } } }),
|
||||
}),
|
||||
]),
|
||||
|
|
@ -189,72 +199,80 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.live("loads markdown agents from config directories in priority order", () =>
|
||||
it.live("loads legacy file-based agents from config directories", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const local = path.join(tmp.path, ".opencode")
|
||||
return Effect.gen(function* () {
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(global, "agent"), { recursive: true })
|
||||
await fs.mkdir(path.join(local, "agents", "team"), { recursive: true })
|
||||
await fs.mkdir(path.join(tmp.path, "agents", "team"), { recursive: true })
|
||||
await fs.mkdir(path.join(tmp.path, "modes"), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(global, "agent", "reviewer.md"),
|
||||
path.join(tmp.path, "agents", "reviewer.md"),
|
||||
`---
|
||||
description: Global reviewer
|
||||
mode: subagent
|
||||
model: openrouter/openai/gpt-5
|
||||
description: Markdown description
|
||||
temperature: 0.5
|
||||
tools:
|
||||
write: false
|
||||
---
|
||||
Review carefully.`,
|
||||
)
|
||||
await fs.writeFile(path.join(tmp.path, "agents", "team", "helper.md"), "Help the team.")
|
||||
await fs.writeFile(
|
||||
path.join(tmp.path, "agents", "native.md"),
|
||||
`---
|
||||
request:
|
||||
headers:
|
||||
x-agent: native
|
||||
body:
|
||||
effort: high
|
||||
permissions:
|
||||
- action: edit
|
||||
resource: "*"
|
||||
effect: deny
|
||||
---
|
||||
Review globally.`,
|
||||
Use native v2 fields.`,
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(local, "agents", "reviewer.md"),
|
||||
`---
|
||||
description: Local reviewer
|
||||
model: anthropic/claude-sonnet
|
||||
---
|
||||
Review locally.`,
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(local, "agents", "team", "research.md"),
|
||||
`---
|
||||
mode: subagent
|
||||
---
|
||||
Research the issue.`,
|
||||
)
|
||||
await fs.writeFile(path.join(local, "agents", "build.md"), "---\ndisabled: true\n---\n")
|
||||
await fs.writeFile(path.join(tmp.path, "agents", "disabled.md"), "---\ndisabled: true\n---\nDisabled")
|
||||
await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
|
||||
})
|
||||
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.update((editor) => editor.update(AgentV2.ID.make("build"), () => {}))
|
||||
const config = Config.Service.of({
|
||||
directories: () => Effect.succeed([AbsolutePath.make(global), AbsolutePath.make(local)]),
|
||||
get: () => Effect.succeed([]),
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({ agents: { reviewer: { description: "JSON description" } } }),
|
||||
}),
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
|
||||
]),
|
||||
})
|
||||
|
||||
yield* ConfigAgentPlugin.Plugin.effect.pipe(Effect.provideService(Config.Service, config))
|
||||
yield* ConfigAgentPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
)
|
||||
|
||||
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
|
||||
expect(reviewer).toMatchObject({
|
||||
system: "Review locally.",
|
||||
description: "Local reviewer",
|
||||
mode: "subagent",
|
||||
model: { providerID: "anthropic", id: "claude-sonnet" },
|
||||
expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({
|
||||
model: { providerID: "openrouter", id: "openai/gpt-5" },
|
||||
system: "Review carefully.",
|
||||
description: "Markdown description",
|
||||
request: { body: { temperature: 0.5 } },
|
||||
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(PermissionV2.evaluate("edit", "src/index.ts", reviewer?.permissions ?? []).effect).toBe("deny")
|
||||
expect(yield* agents.get(AgentV2.ID.make("team/research"))).toMatchObject({
|
||||
system: "Research the issue.",
|
||||
mode: "subagent",
|
||||
expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
|
||||
expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
|
||||
system: "Use native v2 fields.",
|
||||
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
|
||||
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(yield* agents.get(AgentV2.ID.make("build"))).toBeUndefined()
|
||||
})
|
||||
}),
|
||||
expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
|
||||
expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
81
packages/core/test/config/command.test.ts
Normal file
81
packages/core/test/config/command.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(CommandV2.locationLayer, FSUtil.defaultLayer))
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("ConfigCommandPlugin.Plugin", () => {
|
||||
it.live("loads inline and file-based commands in config order", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "commands", "nested"), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmp.path, "commands", "review.md"),
|
||||
`---
|
||||
description: File review
|
||||
agent: reviewer
|
||||
model: anthropic/claude
|
||||
variant: high
|
||||
subtask: true
|
||||
---
|
||||
Review files`,
|
||||
)
|
||||
await fs.writeFile(path.join(tmp.path, "commands", "nested", "docs.md"), "Write docs")
|
||||
await fs.writeFile(path.join(tmp.path, "commands", "empty.md"), "")
|
||||
})
|
||||
|
||||
const command = yield* CommandV2.Service
|
||||
yield* ConfigCommandPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(CommandV2.Service, command),
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({ commands: { review: { template: "Inline review" } } }),
|
||||
}),
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* command.list()).toEqual([
|
||||
new CommandV2.Info({
|
||||
name: "review",
|
||||
template: "Review files",
|
||||
description: "File review",
|
||||
agent: "reviewer",
|
||||
model: {
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
id: ModelV2.ID.make("claude"),
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
},
|
||||
subtask: true,
|
||||
}),
|
||||
new CommandV2.Info({ name: "empty", template: "" }),
|
||||
new CommandV2.Info({ name: "nested/docs", template: "Write docs" }),
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
|
|
@ -23,7 +26,7 @@ function testLayer(
|
|||
vcs?: Project.Vcs,
|
||||
) {
|
||||
return Config.locationLayer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Global.layerWith({ config: globalDirectory })),
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
|
|
@ -40,19 +43,91 @@ function testLayer(
|
|||
}
|
||||
|
||||
const provider = {
|
||||
endpoint: { type: "unknown" },
|
||||
options: {
|
||||
api: { type: "native", settings: {} },
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
},
|
||||
models: {},
|
||||
}
|
||||
|
||||
describe("Config", () => {
|
||||
it.effect("detects v1 configuration from any v1-only top-level key", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
|
||||
expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
|
||||
expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
|
||||
Effect.sync(() => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(Schema.toArbitrary(ConfigV1.Info), (info) => {
|
||||
Schema.decodeUnknownSync(Config.Info)(ConfigMigrateV1.migrate(info), { errors: "all" })
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates v1 provider setup options into AISDK settings", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
bedrock: {
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
options: {
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
region: "us-east-1",
|
||||
profile: "dev",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.bedrock?.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
url: undefined,
|
||||
settings: { region: "us-east-1", profile: "dev" },
|
||||
})
|
||||
expect(migrated.providers?.bedrock?.request).toEqual({
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates v1 command configuration", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
command: {
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
},
|
||||
},
|
||||
}).commands,
|
||||
).toEqual({
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns an empty configuration when directory files do not exist", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
@ -61,9 +136,11 @@ describe("Config", () => {
|
|||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const documents = yield* config.get()
|
||||
const entries = yield* config.entries()
|
||||
|
||||
expect(documents).toEqual([])
|
||||
expect(entries).toEqual([
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) }),
|
||||
])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path))),
|
||||
),
|
||||
),
|
||||
|
|
@ -98,21 +175,23 @@ describe("Config", () => {
|
|||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const documents = yield* config.get()
|
||||
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
|
||||
|
||||
expect(documents).toHaveLength(3)
|
||||
expect(documents.map((document) => document.source.type)).toEqual(["file", "file", "file"])
|
||||
expect(documents.map((document) => document.type)).toEqual(["document", "document", "document"])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
|
||||
expect(documents[0]).toBeInstanceOf(Config.Loaded)
|
||||
expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe(
|
||||
path.join(tmp.path, "config.json"),
|
||||
)
|
||||
expect(documents[0]).toBeInstanceOf(Config.Document)
|
||||
expect(documents[0]?.path).toBe(path.join(tmp.path, "config.json"))
|
||||
expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
|
||||
)
|
||||
expect((yield* config.get()).map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
|
||||
expect(
|
||||
(yield* config.entries())
|
||||
.filter((entry) => entry.type === "document")
|
||||
.map((document) => document.info.$schema),
|
||||
).toEqual(["base", "middle", "last"])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
),
|
||||
|
|
@ -136,7 +215,7 @@ describe("Config", () => {
|
|||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const documents = yield* config.get()
|
||||
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
|
||||
|
||||
expect(documents[0]?.info.$schema).toBeUndefined()
|
||||
expect(documents[0]?.info.shell).toBe("/bin/zsh")
|
||||
|
|
@ -177,9 +256,9 @@ describe("Config", () => {
|
|||
reviewer: {
|
||||
model: "openrouter/openai/gpt-5",
|
||||
variant: "high",
|
||||
options: {
|
||||
request: {
|
||||
headers: { "x-agent": "reviewer" },
|
||||
aisdk: { request: { reasoningEffort: "high" } },
|
||||
body: { reasoningEffort: "high" },
|
||||
},
|
||||
description: "Review changes for correctness",
|
||||
system: "Find regressions.",
|
||||
|
|
@ -244,7 +323,7 @@ describe("Config", () => {
|
|||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const documents = yield* config.get()
|
||||
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
|
||||
|
||||
expect(documents).toHaveLength(1)
|
||||
expect(documents[0]?.info.shell).toBe("/bin/bash")
|
||||
|
|
@ -257,22 +336,21 @@ describe("Config", () => {
|
|||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "bash", resource: "git status", effect: "allow" },
|
||||
])
|
||||
expect(documents[0]?.info.agents?.reviewer).toEqual({
|
||||
model: "openrouter/openai/gpt-5",
|
||||
variant: "high",
|
||||
options: {
|
||||
headers: { "x-agent": "reviewer" },
|
||||
aisdk: { request: { reasoningEffort: "high" } },
|
||||
},
|
||||
description: "Review changes for correctness",
|
||||
system: "Find regressions.",
|
||||
mode: "subagent",
|
||||
hidden: false,
|
||||
color: "warning",
|
||||
steps: 12,
|
||||
disabled: false,
|
||||
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
|
||||
const reviewer = documents[0]?.info.agents?.reviewer
|
||||
expect(reviewer?.model).toBe("openrouter/openai/gpt-5")
|
||||
expect(reviewer?.variant).toBe("high")
|
||||
expect(reviewer?.request).toEqual({
|
||||
headers: { "x-agent": "reviewer" },
|
||||
body: { reasoningEffort: "high" },
|
||||
})
|
||||
expect(reviewer?.description).toBe("Review changes for correctness")
|
||||
expect(reviewer?.system).toBe("Find regressions.")
|
||||
expect(reviewer?.mode).toBe("subagent")
|
||||
expect(reviewer?.hidden).toBe(false)
|
||||
expect(reviewer?.color).toBe("warning")
|
||||
expect(reviewer?.steps).toBe(12)
|
||||
expect(reviewer?.disabled).toBe(false)
|
||||
expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }])
|
||||
expect(documents[0]?.info.snapshots).toBe(false)
|
||||
expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
|
||||
expect(documents[0]?.info.formatter).toEqual({
|
||||
|
|
@ -337,6 +415,135 @@ describe("Config", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("migrates v1 configuration when a v1-only key is present", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
shell: "/bin/zsh",
|
||||
snapshot: false,
|
||||
autoshare: true,
|
||||
permission: {
|
||||
bash: "ask",
|
||||
edit: { "*.md": "allow", "*": "deny" },
|
||||
},
|
||||
agent: {
|
||||
reviewer: {
|
||||
prompt: "Review changes.",
|
||||
disable: true,
|
||||
temperature: 0.2,
|
||||
permission: { read: "allow" },
|
||||
},
|
||||
},
|
||||
plugin: [
|
||||
"opencode-helicone-session",
|
||||
["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
|
||||
],
|
||||
skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
|
||||
reference: { docs: { path: "../docs" } },
|
||||
attachment: { image: { auto_resize: false, max_width: 1200 } },
|
||||
provider: {
|
||||
custom: {
|
||||
options: { apiKey: "secret" },
|
||||
models: {
|
||||
model: {
|
||||
options: { reasoningEffort: "high" },
|
||||
variants: { fast: { temperature: 0.2 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
openai: {
|
||||
npm: "@ai-sdk/openai",
|
||||
options: { apiKey: "secret", organization: "org" },
|
||||
models: {
|
||||
model: { options: { reasoningEffort: "high", serviceTier: "priority" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
compaction: { auto: true, tail_turns: 3, preserve_recent_tokens: 2000, reserved: 10000 },
|
||||
experimental: { mcp_timeout: 5000 },
|
||||
mcp: {
|
||||
local: { type: "local", command: ["node", "server.js"], enabled: false },
|
||||
remote: {
|
||||
type: "remote",
|
||||
url: "https://mcp.example.com",
|
||||
oauth: { clientId: "client", callbackPort: 19876 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
|
||||
|
||||
expect(documents).toHaveLength(1)
|
||||
expect(documents[0]?.info).toBeInstanceOf(Config.Info)
|
||||
expect(documents[0]?.info.shell).toBe("/bin/zsh")
|
||||
expect(documents[0]?.info.snapshots).toBe(false)
|
||||
expect(documents[0]?.info.share).toBe("auto")
|
||||
expect(documents[0]?.info.permissions).toEqual([
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "edit", resource: "*.md", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
])
|
||||
expect(documents[0]?.info.agents?.reviewer).toMatchObject({
|
||||
system: "Review changes.",
|
||||
disabled: true,
|
||||
request: { body: { temperature: 0.2 } },
|
||||
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
||||
})
|
||||
expect(documents[0]?.info.plugins).toEqual([
|
||||
"opencode-helicone-session",
|
||||
{ package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
|
||||
])
|
||||
expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
|
||||
expect(documents[0]?.info.references).toEqual({ docs: { path: "../docs" } })
|
||||
expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
|
||||
expect(documents[0]?.info.providers?.custom).toMatchObject({
|
||||
request: { body: { apiKey: "secret" } },
|
||||
models: {
|
||||
model: {
|
||||
request: { body: { reasoningEffort: "high" } },
|
||||
variants: [{ id: "fast", body: { temperature: 0.2 } }],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(documents[0]?.info.providers?.openai).toMatchObject({
|
||||
api: { settings: {} },
|
||||
request: { headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" } },
|
||||
models: { model: { request: { body: { reasoning_effort: "high", service_tier: "priority" } } } },
|
||||
})
|
||||
expect(documents[0]?.info.compaction).toEqual({
|
||||
auto: true,
|
||||
prune: undefined,
|
||||
keep: { turns: 3, tokens: 2000 },
|
||||
buffer: 10000,
|
||||
})
|
||||
expect(documents[0]?.info.mcp).toMatchObject({
|
||||
timeout: 5000,
|
||||
servers: {
|
||||
local: { type: "local", command: ["node", "server.js"], disabled: true },
|
||||
remote: {
|
||||
type: "remote",
|
||||
url: "https://mcp.example.com",
|
||||
oauth: { client_id: "client", callback_port: 19876 },
|
||||
},
|
||||
},
|
||||
})
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("ignores invalid files while loading valid config values", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
@ -353,7 +560,7 @@ describe("Config", () => {
|
|||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const documents = yield* config.get()
|
||||
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
|
||||
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
|
|
@ -428,10 +635,10 @@ describe("Config", () => {
|
|||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const directories = yield* config.directories()
|
||||
const documents = yield* config.get()
|
||||
const entries = yield* config.entries()
|
||||
const documents = entries.filter((entry) => entry.type === "document")
|
||||
|
||||
expect(directories).toEqual([
|
||||
expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
|
||||
AbsolutePath.make(global),
|
||||
AbsolutePath.make(path.join(root, ".opencode")),
|
||||
AbsolutePath.make(path.join(directory, ".opencode")),
|
||||
|
|
@ -444,6 +651,17 @@ describe("Config", () => {
|
|||
"root-dot",
|
||||
"directory-dot",
|
||||
])
|
||||
expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
|
||||
"global",
|
||||
AbsolutePath.make(global),
|
||||
"root",
|
||||
"parent",
|
||||
"directory",
|
||||
"root-dot",
|
||||
AbsolutePath.make(path.join(root, ".opencode")),
|
||||
"directory-dot",
|
||||
AbsolutePath.make(path.join(directory, ".opencode")),
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer(directory, global, root, {
|
||||
|
|
|
|||
211
packages/core/test/config/provider-options.test.ts
Normal file
211
packages/core/test/config/provider-options.test.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigProviderOptionsV1 } from "@opencode-ai/core/v1/config/provider-options"
|
||||
|
||||
describe("ConfigProviderOptionsV1", () => {
|
||||
test("keeps raw provider and request options unchanged", () => {
|
||||
const lowerer = ConfigProviderOptionsV1.get("custom-provider")
|
||||
|
||||
expect(lowerer.provider({ apiKey: "secret", headers: { "x-test": "1" }, nested: { camelCase: true } })).toEqual({
|
||||
body: { apiKey: "secret", headers: { "x-test": "1" }, nested: { camelCase: true } },
|
||||
})
|
||||
expect(lowerer.request({ nested: { camelCase: true } })).toEqual({ nested: { camelCase: true } })
|
||||
})
|
||||
|
||||
test("falls back to raw lowering for prototype property package names", () => {
|
||||
expect(ConfigProviderOptionsV1.get("toString").provider({ enabled: true })).toEqual({ body: { enabled: true } })
|
||||
})
|
||||
|
||||
test("lowers OpenAI provider and request options", () => {
|
||||
const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/openai")
|
||||
|
||||
expect(
|
||||
lowerer.provider({
|
||||
apiKey: "secret",
|
||||
baseURL: "https://openai.example/v1",
|
||||
organization: "org",
|
||||
project: "project",
|
||||
headers: { "x-test": "1" },
|
||||
body: { store: true },
|
||||
timeout: 1000,
|
||||
}),
|
||||
).toEqual({
|
||||
url: "https://openai.example/v1",
|
||||
headers: {
|
||||
Authorization: "Bearer secret",
|
||||
"OpenAI-Organization": "org",
|
||||
"OpenAI-Project": "project",
|
||||
"x-test": "1",
|
||||
},
|
||||
body: { store: true },
|
||||
settings: { timeout: 1000 },
|
||||
})
|
||||
expect(lowerer.request({ reasoningEffort: "high", nestedValue: { camelCase: true } })).toEqual({
|
||||
reasoning_effort: "high",
|
||||
nested_value: { camel_case: true },
|
||||
})
|
||||
})
|
||||
|
||||
test("lowers Anthropic provider and request options", () => {
|
||||
const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/anthropic")
|
||||
|
||||
expect(
|
||||
lowerer.provider({
|
||||
apiKey: "secret",
|
||||
authToken: "token",
|
||||
baseURL: "https://anthropic.example",
|
||||
headers: { "x-test": "1" },
|
||||
body: { beta: true },
|
||||
generateId: "custom",
|
||||
}),
|
||||
).toEqual({
|
||||
url: "https://anthropic.example",
|
||||
headers: { "x-api-key": "secret", Authorization: "Bearer token", "x-test": "1" },
|
||||
body: { beta: true },
|
||||
settings: { generateId: "custom" },
|
||||
})
|
||||
expect(
|
||||
lowerer.request({
|
||||
effort: "high",
|
||||
taskBudget: 1024,
|
||||
metadata: { userId: "user", traceId: "trace" },
|
||||
nestedValue: { camelCase: true },
|
||||
}),
|
||||
).toEqual({
|
||||
output_config: { effort: "high", task_budget: 1024 },
|
||||
metadata: { user_id: "user", trace_id: "trace" },
|
||||
nested_value: { camel_case: true },
|
||||
})
|
||||
})
|
||||
|
||||
test("lowers Google provider and request options", () => {
|
||||
const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/google")
|
||||
|
||||
expect(
|
||||
lowerer.provider({
|
||||
apiKey: "secret",
|
||||
baseURL: "https://google.example",
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
project: "project",
|
||||
}),
|
||||
).toEqual({
|
||||
url: "https://google.example",
|
||||
headers: { "x-goog-api-key": "secret", "x-test": "1" },
|
||||
body: { trace: true },
|
||||
settings: { project: "project" },
|
||||
})
|
||||
expect(
|
||||
lowerer.request({
|
||||
thinkingConfig: { thinkingBudget: 1024 },
|
||||
responseModalities: ["TEXT"],
|
||||
mediaResolution: "high",
|
||||
imageConfig: { aspectRatio: "16:9" },
|
||||
safetySettings: ["safe"],
|
||||
}),
|
||||
).toEqual({
|
||||
safetySettings: ["safe"],
|
||||
generationConfig: {
|
||||
thinkingConfig: { thinkingBudget: 1024 },
|
||||
responseModalities: ["TEXT"],
|
||||
mediaResolution: "high",
|
||||
imageConfig: { aspectRatio: "16:9" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("lowers Azure provider options and uses OpenAI request lowering", () => {
|
||||
const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/azure")
|
||||
|
||||
expect(
|
||||
lowerer.provider({
|
||||
apiKey: "secret",
|
||||
baseURL: "https://azure.example",
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
resourceName: "resource",
|
||||
}),
|
||||
).toEqual({
|
||||
url: "https://azure.example",
|
||||
headers: { "api-key": "secret", "x-test": "1" },
|
||||
body: { trace: true },
|
||||
settings: { resourceName: "resource" },
|
||||
})
|
||||
expect(lowerer.request({ reasoningEffort: "high" })).toEqual({ reasoning_effort: "high" })
|
||||
})
|
||||
|
||||
test("lowers Amazon Bedrock provider and request options", () => {
|
||||
const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/amazon-bedrock")
|
||||
|
||||
expect(
|
||||
lowerer.provider({
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
region: "us-east-1",
|
||||
profile: "dev",
|
||||
}),
|
||||
).toEqual({
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
settings: { region: "us-east-1", profile: "dev" },
|
||||
})
|
||||
expect(lowerer.request({ temperature: 0.2 })).toEqual({
|
||||
additionalModelRequestFields: { temperature: 0.2 },
|
||||
})
|
||||
})
|
||||
|
||||
test("lowers OpenAI-compatible provider and request options", () => {
|
||||
const lowerer = ConfigProviderOptionsV1.get("@ai-sdk/openai-compatible")
|
||||
|
||||
expect(
|
||||
lowerer.provider({
|
||||
baseURL: "https://compatible.example/v1",
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
apiKey: "secret",
|
||||
}),
|
||||
).toEqual({
|
||||
url: "https://compatible.example/v1",
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
settings: { apiKey: "secret" },
|
||||
})
|
||||
expect(lowerer.request({ reasoningEffort: "high", serviceTier: "priority" })).toEqual({
|
||||
reasoning_effort: "high",
|
||||
serviceTier: "priority",
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
"@ai-sdk/cerebras",
|
||||
"@ai-sdk/deepinfra",
|
||||
"@ai-sdk/groq",
|
||||
"@ai-sdk/mistral",
|
||||
"@ai-sdk/togetherai",
|
||||
"@ai-sdk/xai",
|
||||
"@openrouter/ai-sdk-provider",
|
||||
"ai-gateway-provider",
|
||||
"venice-ai-sdk-provider",
|
||||
])("uses OpenAI-compatible lowering for %s", (packageName) => {
|
||||
const lowerer = ConfigProviderOptionsV1.get(packageName)
|
||||
|
||||
expect(lowerer.provider({ baseURL: "https://example.test", apiKey: "secret" })).toEqual({
|
||||
url: "https://example.test",
|
||||
headers: undefined,
|
||||
body: undefined,
|
||||
settings: { apiKey: "secret" },
|
||||
})
|
||||
expect(lowerer.request({ reasoningEffort: "high" })).toEqual({ reasoning_effort: "high" })
|
||||
})
|
||||
|
||||
test.each(["@ai-sdk/google-vertex", "@ai-sdk/google-vertex/anthropic"])(
|
||||
"uses provider family lowering for %s",
|
||||
(packageName) => {
|
||||
const lowerer = ConfigProviderOptionsV1.get(packageName)
|
||||
|
||||
expect(lowerer.provider({ baseURL: "https://example.test", profile: "dev" })).toMatchObject({
|
||||
url: "https://example.test",
|
||||
settings: { profile: "dev" },
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
@ -8,7 +8,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
|
|||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it } from "../plugin/provider-helper"
|
||||
|
||||
function options(headers: Record<string, string>, variant?: string) {
|
||||
function request(headers: Record<string, string>, variant?: string) {
|
||||
return {
|
||||
headers,
|
||||
variant,
|
||||
|
|
@ -25,18 +25,17 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
const providerID = ProviderV2.ID.make("custom")
|
||||
const modelID = ModelV2.ID.make("chat")
|
||||
const config = Config.Service.of({
|
||||
directories: () => Effect.succeed([]),
|
||||
get: () =>
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: {
|
||||
name: "Configured",
|
||||
env: ["CUSTOM_API_KEY"],
|
||||
endpoint: { type: "unknown" },
|
||||
options: options({ first: "first", shared: "first" }),
|
||||
api: { type: "native", settings: {} },
|
||||
request: request({ first: "first", shared: "first" }),
|
||||
models: {
|
||||
chat: {
|
||||
name: "First",
|
||||
|
|
@ -44,7 +43,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
disabled: true,
|
||||
limit: { context: 100, output: 50 },
|
||||
cost: { input: 1, output: 2 },
|
||||
options: options({ first: "first", shared: "first" }, "retained"),
|
||||
request: request({ first: "first", shared: "first" }, "retained"),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
|
|
@ -57,19 +56,19 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: {
|
||||
endpoint: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
|
||||
options: options({ last: "last", shared: "last" }),
|
||||
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
models: {
|
||||
chat: {
|
||||
api_id: "api-chat",
|
||||
api: { id: "api-chat" },
|
||||
name: "Last",
|
||||
limit: { output: 75 },
|
||||
options: options({ last: "last", shared: "last" }),
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
|
|
@ -86,8 +85,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: { name: "Renamed" },
|
||||
|
|
@ -110,16 +109,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
expect(provider.name).toBe("Renamed")
|
||||
expect(provider.env).toEqual(["CUSTOM_API_KEY"])
|
||||
expect(provider.enabled).toEqual({ via: "custom", data: {} })
|
||||
expect(provider.endpoint).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
|
||||
expect(provider.options.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.apiID).toBe(ModelV2.ID.make("api-chat"))
|
||||
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
|
||||
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
|
||||
expect(model.name).toBe("Last")
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
|
||||
expect(model.options.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.options.variant).toBe("retained")
|
||||
expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.request.variant).toBe("retained")
|
||||
expect(model.variants.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("fast"),
|
||||
ModelV2.VariantID.make("slow"),
|
||||
|
|
|
|||
78
packages/core/test/config/skill.test.ts
Normal file
78
packages/core/test/config/skill.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("ConfigSkillPlugin.Plugin", () => {
|
||||
it.effect("registers configured skill directories and URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = AbsolutePath.make("/repo/packages/app")
|
||||
const sources: SkillV2.Source[] = []
|
||||
const transform = Effect.fnUntraced(function* () {
|
||||
return Effect.fnUntraced(function* (update: (editor: SkillV2.Editor) => void) {
|
||||
update({
|
||||
source: (source) => sources.push(source),
|
||||
list: () => sources,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
yield* ConfigSkillPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
Effect.provideService(Global.Service, Global.Service.of(Global.make({ home: "/home/test" }))),
|
||||
Effect.provideService(Location.Service, Location.Service.of(location({ directory }))),
|
||||
Effect.provideService(
|
||||
SkillV2.Service,
|
||||
SkillV2.Service.of({
|
||||
transform,
|
||||
sources: () => Effect.succeed(sources),
|
||||
list: () => Effect.succeed([]),
|
||||
forAgent: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(sources).toEqual([
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),
|
||||
}),
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.opencode", "skills")),
|
||||
}),
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/home/test", "shared-skills")),
|
||||
}),
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make("/opt/skills") }),
|
||||
new SkillV2.UrlSource({ type: "url", url: "https://example.test/skills/" }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "url"
|
||||
import path from "path"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { eq, inArray, sql } from "drizzle-orm"
|
||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
|
||||
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
|
||||
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
|
@ -15,6 +17,8 @@ import { SessionSchema } from "@opencode-ai/core/session/schema"
|
|||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
||||
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
||||
Effect.runPromise(
|
||||
|
|
@ -24,6 +28,18 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
|||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
|
||||
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)]
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.all(
|
||||
layers.map((layer) => Effect.scoped(Layer.build(layer))),
|
||||
{ concurrency: "unbounded" },
|
||||
),
|
||||
)
|
||||
})
|
||||
if (process.platform === "linux") {
|
||||
test("declared schema has no ungenerated migrations", async () => {
|
||||
const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`
|
||||
|
|
@ -43,7 +59,77 @@ describe("DatabaseMigration", () => {
|
|||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
|
||||
name: "session",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 24 })
|
||||
expect(
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
|
||||
).toEqual({ name: "session_input" })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 29 })
|
||||
expect(
|
||||
yield* db.all(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||
),
|
||||
).toEqual([
|
||||
{ name: "event_aggregate_seq_idx" },
|
||||
{ name: "event_aggregate_type_seq_idx" },
|
||||
{ name: "session_input_session_pending_delivery_seq_idx" },
|
||||
{ name: "session_message_session_seq_idx" },
|
||||
{ name: "session_message_session_time_created_id_idx" },
|
||||
{ name: "session_message_session_type_seq_idx" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("resets incompatible projected Session messages before adding sequence order", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
|
||||
)
|
||||
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE INDEX session_message_session_time_created_id_idx ON session_message (session_id, time_created, id)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE INDEX session_message_session_type_time_created_id_idx ON session_message (session_id, type, time_created, id)`,
|
||||
)
|
||||
yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('legacy_message', 'session', 1, 1, '{"role":"user"}')`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('legacy_part', 'legacy_message', 'session', 1, 1, '{"type":"text","text":"hello"}')`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('stale_projection', 'session', 'user', 1, 1, '{}')`,
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT id, session_id, data FROM message`)).toEqual([
|
||||
{ id: "legacy_message", session_id: "session", data: '{"role":"user"}' },
|
||||
])
|
||||
expect(yield* db.all(sql`SELECT id, message_id, session_id, data FROM part`)).toEqual([
|
||||
{
|
||||
id: "legacy_part",
|
||||
message_id: "legacy_message",
|
||||
session_id: "session",
|
||||
data: '{"type":"text","text":"hello"}',
|
||||
},
|
||||
])
|
||||
expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([])
|
||||
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('fresh_projection', 'session', 'user', 7, 2, 2, '{}')`,
|
||||
)
|
||||
expect(yield* db.get(sql`SELECT id, seq FROM session_message`)).toEqual({ id: "fresh_projection", seq: 7 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
73
packages/core/test/effect/keyed-mutex.test.ts
Normal file
73
packages/core/test/effect/keyed-mutex.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import { KeyedMutex } from "@opencode-ai/core/effect/keyed-mutex"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("KeyedMutex", () => {
|
||||
it.effect("serializes effects with the same key", () =>
|
||||
Effect.gen(function* () {
|
||||
const mutex = yield* KeyedMutex.make<string>()
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
|
||||
const first = yield* mutex
|
||||
.withLock("shared")(
|
||||
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
||||
)
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* mutex.withLock("shared")(Deferred.succeed(secondStarted, undefined)).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(yield* mutex.size).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows different keys to proceed independently", () =>
|
||||
Effect.gen(function* () {
|
||||
const mutex = yield* KeyedMutex.make<string>()
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondFinished = yield* Deferred.make<void>()
|
||||
|
||||
const first = yield* mutex
|
||||
.withLock("first")(Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))))
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* mutex.withLock("second")(Deferred.succeed(secondFinished, undefined))
|
||||
expect(yield* Deferred.isDone(secondFinished)).toBe(true)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
expect(yield* mutex.size).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes an interrupted waiter without dropping the holder lock", () =>
|
||||
Effect.gen(function* () {
|
||||
const mutex = yield* KeyedMutex.make<string>()
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
|
||||
const first = yield* mutex
|
||||
.withLock("shared")(
|
||||
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
||||
)
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const interrupted = yield* mutex.withLock("shared")(Effect.void).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* Fiber.interrupt(interrupted)
|
||||
expect(yield* mutex.size).toBe(1)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
expect(yield* mutex.size).toBe(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,17 +1,21 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { V2Schema } from "@opencode-ai/core/v2-schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })),
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") }),
|
||||
),
|
||||
)
|
||||
const eventLayer = Layer.mergeAll(EventV2.defaultLayer, Database.defaultLayer)
|
||||
const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
|
||||
|
|
@ -67,7 +71,32 @@ const VersionedMessage = EventV2.define({
|
|||
},
|
||||
})
|
||||
|
||||
const SyncTimestamp = EventV2.define({
|
||||
type: "test.timestamp",
|
||||
sync: {
|
||||
version: 1,
|
||||
aggregate: "id",
|
||||
},
|
||||
schema: {
|
||||
id: Schema.String,
|
||||
timestamp: V2Schema.DateTimeUtcFromMillis,
|
||||
},
|
||||
})
|
||||
|
||||
describe("EventV2", () => {
|
||||
it.effect("derives stable namespaced external IDs", () =>
|
||||
Effect.sync(() => {
|
||||
const input = { namespace: "opencord.agent-input", key: "input-1" }
|
||||
|
||||
expect(EventV2.ID.fromExternal(input)).toBe(EventV2.ID.fromExternal(input))
|
||||
expect(EventV2.ID.fromExternal(input)).toMatch(/^evt_[a-f0-9]{64}$/)
|
||||
expect(EventV2.ID.fromExternal({ ...input, namespace: "another-app" })).not.toBe(EventV2.ID.fromExternal(input))
|
||||
expect(EventV2.ID.fromExternal({ namespace: "a:b", key: "c" })).not.toBe(
|
||||
EventV2.ID.fromExternal({ namespace: "a", key: "b:c" }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes events with the current location", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -80,7 +109,10 @@ describe("EventV2", () => {
|
|||
expect(event.type).toBe("test.message")
|
||||
expect(event).not.toHaveProperty("version")
|
||||
expect(event.data).toEqual({ text: "hello" })
|
||||
expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })
|
||||
expect(event.location).toEqual({
|
||||
directory: AbsolutePath.make("project"),
|
||||
workspaceID: WorkspaceV2.ID.make("wrk_test"),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -204,6 +236,24 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("does not synchronize live-only events", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const synchronized = new Array<string>()
|
||||
const unsubscribe = yield* events.sync((event) =>
|
||||
Effect.sync(() => {
|
||||
synchronized.push(event.type)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
yield* events.publish(Message, { text: "live only" })
|
||||
yield* events.publish(SyncMessage, { id: "one", text: "durable" })
|
||||
|
||||
expect(synchronized).toEqual([SyncMessage.type])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("inserts sync event rows on publish", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -243,6 +293,120 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("replays durable aggregate events after a cursor and tails new events", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
|
||||
const fiber = yield* events
|
||||
.aggregateEvents({ aggregateID, after: EventV2.Cursor.make(0) })
|
||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "two" })
|
||||
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([
|
||||
[EventV2.Cursor.make(1), { id: aggregateID, text: "one" }],
|
||||
[EventV2.Cursor.make(2), { id: aggregateID, text: "two" }],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("catches durable aggregate events published during replay handoff", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
|
||||
const fiber = yield* events
|
||||
.aggregateEvents({ aggregateID })
|
||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
|
||||
|
||||
expect(
|
||||
Array.from(yield* Fiber.join(fiber)).map((event) => [
|
||||
event.cursor,
|
||||
(event.event.data as { text: string }).text,
|
||||
]),
|
||||
).toEqual([
|
||||
[EventV2.Cursor.make(0), "zero"],
|
||||
[EventV2.Cursor.make(1), "one"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains a durable wake committed while historical replay is paused", () =>
|
||||
Effect.gen(function* () {
|
||||
const readStarted = yield* Deferred.make<void>()
|
||||
const continueRead = yield* Deferred.make<void>()
|
||||
let pause = true
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const eventLayer = EventV2.layerWith({
|
||||
beforeAggregateRead: () =>
|
||||
pause
|
||||
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
|
||||
: Effect.void,
|
||||
}).pipe(Layer.provide(database))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const fiber = yield* events
|
||||
.aggregateEvents({ aggregateID })
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Deferred.await(readStarted)
|
||||
|
||||
pause = false
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "during handoff" })
|
||||
yield* Deferred.succeed(continueRead, undefined)
|
||||
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual([
|
||||
[EventV2.Cursor.make(0), { id: aggregateID, text: "during handoff" }],
|
||||
])
|
||||
}).pipe(Effect.provide(Layer.mergeAll(database, eventLayer)))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("coalesces durable aggregate wakes while draining every committed event", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const count = 64
|
||||
const fiber = yield* events
|
||||
.aggregateEvents({ aggregateID })
|
||||
.pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
for (let index = 0; index < count; index++) {
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: String(index) })
|
||||
}
|
||||
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.cursor, event.event.data])).toEqual(
|
||||
Array.from({ length: count }, (_, index) => [
|
||||
EventV2.Cursor.make(index),
|
||||
{ id: aggregateID, text: String(index) },
|
||||
]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits live-only events from durable aggregate streams", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const fiber = yield* events
|
||||
.aggregateEvents({ aggregateID })
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* events.publish(Message, { text: "live only" })
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "durable" })
|
||||
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.event.type)).toEqual([SyncMessage.type])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses custom sync aggregate field", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -311,6 +475,51 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"replay rejects an envelope aggregate that differs from its payload without mutating the payload aggregate",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const envelopeAggregateID = EventV2.ID.create()
|
||||
const payloadAggregateID = EventV2.ID.create()
|
||||
const received = new Array<EventV2.Payload>()
|
||||
yield* events.publish(SyncMessage, { id: payloadAggregateID, text: "seed" })
|
||||
yield* events.project(SyncMessage, (event) =>
|
||||
Effect.sync(() => {
|
||||
received.push(event)
|
||||
}),
|
||||
)
|
||||
|
||||
const exit = yield* events
|
||||
.replay({
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID: envelopeAggregateID,
|
||||
data: { id: payloadAggregateID, text: "replayed" },
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, payloadAggregateID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const sequence = yield* db
|
||||
.select({ seq: EventSequenceTable.seq })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, payloadAggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(String(exit)).toContain("Aggregate mismatch")
|
||||
expect(received).toHaveLength(0)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(sequence).toEqual({ seq: 0 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replay defects on sequence mismatch", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -337,6 +546,29 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("replay decodes synchronized transformed values before projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const received = new Array<typeof SyncTimestamp.Type>()
|
||||
yield* events.project(SyncTimestamp, (event) =>
|
||||
Effect.sync(() => {
|
||||
received.push(event)
|
||||
}),
|
||||
)
|
||||
|
||||
yield* events.replay({
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncTimestamp.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, timestamp: 0 },
|
||||
})
|
||||
|
||||
expect(received[0]?.data.timestamp).toEqual(DateTime.makeUnsafe(0))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replay defects on unknown event type", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -485,11 +717,111 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("replay claims an existing unowned sequence before fencing a different owner", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
yield* events.publish(SyncMessage, { id: aggregateID, text: "local" })
|
||||
|
||||
yield* events.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, text: "claimed" },
|
||||
},
|
||||
{ ownerID: "owner-1" },
|
||||
)
|
||||
yield* events.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 2,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, text: "fenced" },
|
||||
},
|
||||
{ ownerID: "owner-2" },
|
||||
)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, aggregateID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const sequence = yield* db
|
||||
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(rows.map((row) => row.seq)).toEqual([0, 1])
|
||||
expect(sequence).toEqual({ seq: 1, ownerID: "owner-1" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strict replay rejects an owner conflict instead of silently skipping it", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
yield* events.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, text: "claimed" },
|
||||
},
|
||||
{ ownerID: "owner-1" },
|
||||
)
|
||||
|
||||
const exit = yield* events
|
||||
.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, text: "conflict" },
|
||||
},
|
||||
{ ownerID: "owner-2", strictOwner: true },
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(String(exit)).toContain("Replay owner mismatch")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes accepted replay with its durable sequence and suppresses stale replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const received = new Array<EventV2.Payload>()
|
||||
const aggregateID = EventV2.ID.create()
|
||||
yield* events.listen((event) => Effect.sync(() => received.push(event)))
|
||||
const replayed = {
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, text: "replayed" },
|
||||
}
|
||||
|
||||
yield* events.replay(replayed, { publish: true })
|
||||
yield* events.replay(replayed, { publish: true })
|
||||
|
||||
expect(received).toMatchObject([{ id: replayed.id, seq: 0, data: replayed.data }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replay from a different owner leaves claimed sequence unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const received = new Array<EventV2.Payload>()
|
||||
yield* events.listen((event) => Effect.sync(() => received.push(event)))
|
||||
|
||||
yield* events.replay(
|
||||
{
|
||||
|
|
@ -509,7 +841,7 @@ describe("EventV2", () => {
|
|||
aggregateID,
|
||||
data: { id: aggregateID, text: "ignored" },
|
||||
},
|
||||
{ ownerID: "owner-2" },
|
||||
{ ownerID: "owner-2", publish: true },
|
||||
)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
|
|
@ -526,6 +858,7 @@ describe("EventV2", () => {
|
|||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" })
|
||||
expect(received).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
357
packages/core/test/file-mutation.test.ts
Normal file
357
packages/core/test/file-mutation.test.ts
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
function provide(directory: string, filesystem = FSUtil.defaultLayer) {
|
||||
const activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
|
||||
return Effect.provide(Layer.mergeAll(planning, commits))
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
}
|
||||
|
||||
describe("FileMutation", () => {
|
||||
it.live("writes an existing internal file and returns a stable result", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).write({ plan, content: "after" })).toEqual({
|
||||
operation: "write",
|
||||
target: plan.target.canonical,
|
||||
resource: "hello.txt",
|
||||
existed: true,
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("writes a prospective internal file and creates parent directories", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "nested", "hello.txt") })
|
||||
const result = yield* (yield* FileMutation.Service).write({ plan, content: "hello" })
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "write",
|
||||
target: plan.target.canonical,
|
||||
resource: "src/nested/hello.txt",
|
||||
existed: false,
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(result.target, "utf8"))).toBe("hello")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves exactly one BOM for text writes and normalizes created text", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const preservedPath = path.join(directory, "preserved.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
|
||||
const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
|
||||
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
|
||||
const files = yield* FileMutation.Service
|
||||
|
||||
yield* files.writeTextPreservingBom({ plan: preserved, content: "\uFEFFafter" })
|
||||
yield* files.writeTextPreservingBom({ plan: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
||||
|
||||
expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter")
|
||||
expect(yield* Effect.promise(() => fs.readFile(created.target.canonical, "utf8"))).toBe("\uFEFFcreated")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects create when a prospective target appears after planning", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "appeared.txt")
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "appeared.txt" })
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "winner"))
|
||||
|
||||
expect(
|
||||
yield* (yield* FileMutation.Service).create({ plan, content: "replacement" }).pipe(Effect.flip),
|
||||
).toMatchObject({
|
||||
_tag: "LocationMutation.RevalidationError",
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("winner")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("removes an existing internal file", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "remove.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "remove"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "remove.txt" })
|
||||
const result = yield* (yield* FileMutation.Service).remove({ plan })
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "remove",
|
||||
target: plan.target.canonical,
|
||||
resource: "remove.txt",
|
||||
existed: true,
|
||||
})
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
fs.stat(targetPath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("writes an explicitly planned external target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "external.txt")
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const result = yield* (yield* FileMutation.Service).write({ plan, content: "external" })
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "write",
|
||||
target: plan.target.canonical,
|
||||
resource: plan.target.resource,
|
||||
existed: false,
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("external")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("removes an explicitly planned external target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "external.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "external"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const result = yield* (yield* FileMutation.Service).remove({ plan })
|
||||
|
||||
expect(result).toEqual({
|
||||
operation: "remove",
|
||||
target: plan.target.canonical,
|
||||
resource: plan.target.resource,
|
||||
existed: true,
|
||||
})
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
fs.stat(targetPath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("propagates revalidation rejection after an ancestor swap", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const parent = path.join(directory, "parent")
|
||||
yield* Effect.promise(() => fs.mkdir(parent))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: path.join("parent", "new.txt") })
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.rmdir(parent)
|
||||
await fs.symlink(outside, parent)
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* (yield* FileMutation.Service).write({ plan, content: "escape" }).pipe(Effect.flip),
|
||||
).toMatchObject({
|
||||
_tag: "LocationMutation.RevalidationError",
|
||||
})
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
fs.stat(path.join(outside, "new.txt")).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent writes to the same canonical target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "shared.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
let writes = 0
|
||||
const filesystem = instrumentWrites((write) =>
|
||||
Effect.gen(function* () {
|
||||
writes++
|
||||
if (writes === 1) {
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(releaseFirst)
|
||||
} else {
|
||||
yield* Deferred.succeed(secondStarted, undefined)
|
||||
}
|
||||
yield* write
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const first = yield* files.write({ plan: firstPlan, content: "first" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* files.write({ plan: secondPlan, content: "second" }).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("second")
|
||||
}).pipe(provide(directory, filesystem))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows only one concurrent conditional write based on the same bytes", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "shared.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
let writes = 0
|
||||
const filesystem = instrumentWrites((write) =>
|
||||
Effect.gen(function* () {
|
||||
writes++
|
||||
if (writes === 1) {
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(releaseFirst)
|
||||
}
|
||||
yield* write
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const plan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const expected = new TextEncoder().encode("initial")
|
||||
const first = yield* files.writeIfUnchanged({ plan, expected, content: "first" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* files
|
||||
.writeIfUnchanged({ plan, expected, content: "second" })
|
||||
.pipe(Effect.flip, Effect.forkChild)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
expect(yield* Fiber.join(second)).toMatchObject({ _tag: "FileMutation.StaleContentError" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("first")
|
||||
expect(writes).toBe(1)
|
||||
}).pipe(provide(directory, filesystem))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a conditional write when target content is already stale", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "stale.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "current"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "stale.txt" })
|
||||
|
||||
expect(
|
||||
yield* (yield* FileMutation.Service)
|
||||
.writeIfUnchanged({ plan, expected: new TextEncoder().encode("older"), content: "replacement" })
|
||||
.pipe(Effect.flip),
|
||||
).toMatchObject({ _tag: "FileMutation.StaleContentError", path: plan.target.canonical })
|
||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("current")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows distinct canonical targets to proceed independently", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondFinished = yield* Deferred.make<void>()
|
||||
const secondPath = path.join(directory, "second.txt")
|
||||
let writes = 0
|
||||
const filesystem = instrumentWrites((write) =>
|
||||
++writes === 1
|
||||
? Deferred.succeed(firstStarted, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(releaseFirst)),
|
||||
Effect.andThen(write),
|
||||
)
|
||||
: write.pipe(Effect.andThen(Deferred.succeed(secondFinished, undefined))),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const firstPlan = yield* mutation.resolve({ path: "first.txt" })
|
||||
const secondPlan = yield* mutation.resolve({ path: "second.txt" })
|
||||
const first = yield* files.write({ plan: firstPlan, content: "first" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* files.write({ plan: secondPlan, content: "second" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(secondFinished)
|
||||
expect(yield* Effect.promise(() => fs.readFile(secondPath, "utf8"))).toBe("second")
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
}).pipe(provide(directory, filesystem))
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function instrumentWrites(
|
||||
run: (write: Effect.Effect<void, FSUtil.Error>, target: string) => Effect.Effect<void, FSUtil.Error>,
|
||||
) {
|
||||
return Layer.effect(
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const filesystem = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...filesystem,
|
||||
writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
}
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
import { describe, test, expect } from "bun:test"
|
||||
import { Effect, Layer, FileSystem } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import path from "path"
|
||||
|
||||
const live = AppFileSystem.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))
|
||||
const live = FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))
|
||||
const { effect: it } = testEffect(live)
|
||||
|
||||
describe("AppFileSystem", () => {
|
||||
describe("FSUtil", () => {
|
||||
describe("isDir", () => {
|
||||
it(
|
||||
"returns true for directories",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
expect(yield* fs.isDir(tmp)).toBe(true)
|
||||
|
|
@ -23,7 +23,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"returns false for files",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "test.txt")
|
||||
|
|
@ -35,7 +35,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"returns false for non-existent paths",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
|
@ -45,7 +45,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"returns true for files",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "test.txt")
|
||||
|
|
@ -57,7 +57,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"returns false for directories",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
expect(yield* fs.isFile(tmp)).toBe(false)
|
||||
|
|
@ -69,7 +69,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"returns file contents when file exists",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "exists.txt")
|
||||
|
|
@ -83,7 +83,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"returns undefined for missing file (NotFound)",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"round-trips JSON data",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "data.json")
|
||||
|
|
@ -115,7 +115,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"creates nested directories",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
const nested = path.join(tmp, "a", "b", "c")
|
||||
|
|
@ -130,7 +130,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"is idempotent",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
const dir = path.join(tmp, "existing")
|
||||
|
|
@ -148,7 +148,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"creates parent directories if missing",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "deep", "nested", "file.txt")
|
||||
|
|
@ -162,7 +162,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"writes directly when parent exists",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "direct.txt")
|
||||
|
|
@ -176,7 +176,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"writes Uint8Array content",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "binary.bin")
|
||||
|
|
@ -194,7 +194,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"finds target in start directory",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
yield* filesys.writeFileString(path.join(tmp, "target.txt"), "found")
|
||||
|
|
@ -207,7 +207,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"finds target in parent directories",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
yield* filesys.writeFileString(path.join(tmp, "marker"), "root")
|
||||
|
|
@ -222,7 +222,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"returns empty array when not found",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
const result = yield* fs.findUp("nonexistent", tmp, tmp)
|
||||
|
|
@ -235,7 +235,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"finds multiple targets walking up",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
yield* filesys.writeFileString(path.join(tmp, "a.txt"), "a")
|
||||
|
|
@ -257,7 +257,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"finds files matching pattern",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
yield* filesys.writeFileString(path.join(tmp, "a.ts"), "a")
|
||||
|
|
@ -272,7 +272,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"supports absolute paths",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
yield* filesys.writeFileString(path.join(tmp, "file.txt"), "hello")
|
||||
|
|
@ -287,7 +287,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"matches patterns",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
expect(fs.globMatch("*.ts", "foo.ts")).toBe(true)
|
||||
expect(fs.globMatch("*.ts", "foo.json")).toBe(false)
|
||||
expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true)
|
||||
|
|
@ -299,7 +299,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"finds files walking up directories",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
yield* filesys.writeFileString(path.join(tmp, "root.md"), "root")
|
||||
|
|
@ -318,7 +318,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"exists works",
|
||||
Effect.gen(function* () {
|
||||
yield* AppFileSystem.Service
|
||||
yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "exists.txt")
|
||||
|
|
@ -332,7 +332,7 @@ describe("AppFileSystem", () => {
|
|||
it(
|
||||
"remove works",
|
||||
Effect.gen(function* () {
|
||||
yield* AppFileSystem.Service
|
||||
yield* FSUtil.Service
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
const file = path.join(tmp, "delete-me.txt")
|
||||
|
|
@ -347,20 +347,25 @@ describe("AppFileSystem", () => {
|
|||
|
||||
describe("pure helpers", () => {
|
||||
test("mimeType returns correct types", () => {
|
||||
expect(AppFileSystem.mimeType("file.json")).toBe("application/json")
|
||||
expect(AppFileSystem.mimeType("image.png")).toBe("image/png")
|
||||
expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream")
|
||||
expect(FSUtil.mimeType("file.json")).toBe("application/json")
|
||||
expect(FSUtil.mimeType("image.png")).toBe("image/png")
|
||||
expect(FSUtil.mimeType("unknown.qzx")).toBe("application/octet-stream")
|
||||
})
|
||||
|
||||
test("contains checks path containment", () => {
|
||||
expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true)
|
||||
expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false)
|
||||
expect(FSUtil.contains("/a/b", "/a/b/c")).toBe(true)
|
||||
expect(FSUtil.contains("/a/b", "/a/b")).toBe(true)
|
||||
expect(FSUtil.contains("/a/b", "/a/c")).toBe(false)
|
||||
expect(FSUtil.contains("/a/b", "/a/bad")).toBe(false)
|
||||
if (process.platform === "win32") expect(FSUtil.contains("C:\\a", "D:\\b")).toBe(false)
|
||||
})
|
||||
|
||||
test("overlaps detects overlapping paths", () => {
|
||||
expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true)
|
||||
expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true)
|
||||
expect(AppFileSystem.overlaps("/a", "/b")).toBe(false)
|
||||
expect(FSUtil.overlaps("/a/b", "/a/b/c")).toBe(true)
|
||||
expect(FSUtil.overlaps("/a/b/c", "/a/b")).toBe(true)
|
||||
expect(FSUtil.overlaps("/a", "/b")).toBe(false)
|
||||
expect(FSUtil.overlaps("/a/b", "/a/bad")).toBe(false)
|
||||
if (process.platform === "win32") expect(FSUtil.overlaps("C:\\a", "D:\\b")).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
10
packages/core/test/filesystem/ignore.test.ts
Normal file
10
packages/core/test/filesystem/ignore.test.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { Ignore } from "@opencode-ai/core/filesystem/ignore"
|
||||
|
||||
test("match nested and non-nested", () => {
|
||||
expect(Ignore.match("node_modules/index.js")).toBe(true)
|
||||
expect(Ignore.match("node_modules")).toBe(true)
|
||||
expect(Ignore.match("node_modules/")).toBe(true)
|
||||
expect(Ignore.match("node_modules/bar")).toBe(true)
|
||||
expect(Ignore.match("node_modules/bar/")).toBe(true)
|
||||
})
|
||||
231
packages/core/test/filesystem/ripgrep.test.ts
Normal file
231
packages/core/test/filesystem/ripgrep.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Ripgrep.defaultLayer)
|
||||
|
||||
const tmpdir = (init?: (dir: string) => Effect.Effect<void>) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))),
|
||||
(dir) =>
|
||||
Effect.promise(() =>
|
||||
fs.rm(dir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 100,
|
||||
}),
|
||||
).pipe(Effect.ignore),
|
||||
).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
|
||||
|
||||
const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
|
||||
const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
||||
const collectFiles = (input: Ripgrep.FilesInput) =>
|
||||
Ripgrep.Service.use((rg) =>
|
||||
rg.files(input).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.map((c) => [...c]),
|
||||
),
|
||||
)
|
||||
|
||||
const withRipgrepConfig = <A, E, R>(value: string, effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const prev = process.env["RIPGREP_CONFIG_PATH"]
|
||||
process.env["RIPGREP_CONFIG_PATH"] = value
|
||||
return prev
|
||||
}),
|
||||
() => effect,
|
||||
(prev) =>
|
||||
Effect.sync(() => {
|
||||
if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
|
||||
else process.env["RIPGREP_CONFIG_PATH"] = prev
|
||||
}),
|
||||
)
|
||||
|
||||
describe("file.ripgrep", () => {
|
||||
it.live("exposes a cached managed executable filepath", () =>
|
||||
Effect.gen(function* () {
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const first = yield* ripgrep.filepath
|
||||
const second = yield* ripgrep.filepath
|
||||
|
||||
expect(first).toBe(second)
|
||||
expect((yield* Effect.promise(() => fs.stat(first))).isFile()).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("defaults to include hidden", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "visible.txt"), "hello")
|
||||
yield* mkdir(path.join(dir, ".opencode"))
|
||||
yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir })
|
||||
expect(files.includes("visible.txt")).toBe(true)
|
||||
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("hidden false excludes hidden", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "visible.txt"), "hello")
|
||||
yield* mkdir(path.join(dir, ".opencode"))
|
||||
yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir, hidden: false })
|
||||
expect(files.includes("visible.txt")).toBe(true)
|
||||
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("search returns empty when nothing matches", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const value = 'other'\n"))
|
||||
|
||||
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle" })
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("search returns match metadata with normalized path", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* mkdir(path.join(dir, "src"))
|
||||
yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle" })
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
|
||||
expect(result.items[0]?.line_number).toBe(1)
|
||||
expect(result.items[0]?.lines.text).toContain("needle")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("search returns matched rows with glob filter", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
|
||||
yield* write(path.join(dir, "skip.txt"), "const value = 'other'\n")
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle", glob: ["*.ts"] })
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toContain("match.ts")
|
||||
expect(result.items[0]?.lines.text).toContain("needle")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("search supports explicit file targets", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
|
||||
yield* write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
|
||||
}),
|
||||
)
|
||||
|
||||
const file = path.join(dir, "match.ts")
|
||||
const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle", file: [file] })
|
||||
expect(result.partial).toBe(false)
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]?.path.text).toBe(file)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files returns empty when glob matches no files", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* mkdir(path.join(dir, "packages", "console"))
|
||||
yield* write(path.join(dir, "packages", "console", "package.json"), "{}")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir, glob: ["packages/*"] })
|
||||
expect(files).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files returns stream of filenames", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "a.txt"), "hello")
|
||||
yield* write(path.join(dir, "b.txt"), "world")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir }).pipe(Effect.map((files) => files.sort()))
|
||||
expect(files).toEqual(["a.txt", "b.txt"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files respects glob filter", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* write(path.join(dir, "keep.ts"), "yes")
|
||||
yield* write(path.join(dir, "skip.txt"), "no")
|
||||
}),
|
||||
)
|
||||
|
||||
const files = yield* collectFiles({ cwd: dir, glob: ["*.ts"] })
|
||||
expect(files).toEqual(["keep.ts"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("files dies on nonexistent directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Ripgrep.Service.use((rg) =>
|
||||
rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect),
|
||||
).pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores RIPGREP_CONFIG_PATH in direct mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
|
||||
|
||||
const result = yield* withRipgrepConfig(
|
||||
path.join(dir, "missing-ripgreprc"),
|
||||
Ripgrep.use.search({ cwd: dir, pattern: "needle" }),
|
||||
)
|
||||
expect(result.items).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores RIPGREP_CONFIG_PATH in worker mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
|
||||
|
||||
const result = yield* withRipgrepConfig(
|
||||
path.join(dir, "missing-ripgreprc"),
|
||||
Ripgrep.use.search({ cwd: dir, pattern: "needle" }),
|
||||
)
|
||||
expect(result.items).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
271
packages/core/test/filesystem/watcher.test.ts
Normal file
271
packages/core/test/filesystem/watcher.test.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||
|
||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
|
||||
const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))
|
||||
|
||||
const configLayer = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
|
||||
const flagsLayer = ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
|
||||
}),
|
||||
)
|
||||
|
||||
function provide(directory: string, vcs?: Location.Interface["vcs"]) {
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
||||
)
|
||||
return Effect.provide(
|
||||
Watcher.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(locationLayer),
|
||||
Layer.provide(flagsLayer),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(
|
||||
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
|
||||
options?: { git?: boolean; init?: (directory: string) => Promise<void> },
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const tmp = await tmpdir()
|
||||
if (!options?.git) return { tmp, vcs: undefined }
|
||||
await $`git init`.cwd(tmp.path).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
|
||||
await $`git config user.name Test`.cwd(tmp.path).quiet()
|
||||
await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
|
||||
await options.init?.(tmp.path)
|
||||
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
|
||||
}),
|
||||
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs))))
|
||||
}
|
||||
|
||||
function wait(check: (event: WatcherEvent) => boolean) {
|
||||
return Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const deferred = yield* Deferred.make<WatcherEvent>()
|
||||
const fiber = yield* events.subscribe(Watcher.Event.Updated).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (!check(event.data)) return Effect.void
|
||||
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
|
||||
}),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
return { deferred, fiber }
|
||||
})
|
||||
}
|
||||
|
||||
function maybeNextUpdate<E>(
|
||||
check: (event: WatcherEvent) => boolean,
|
||||
trigger: Effect.Effect<void, E>,
|
||||
timeout: Duration.Input = "5 seconds",
|
||||
) {
|
||||
return Effect.acquireUseRelease(
|
||||
wait(check),
|
||||
({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
}
|
||||
|
||||
function nextUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
|
||||
return Effect.gen(function* () {
|
||||
const result = yield* maybeNextUpdate(check, trigger)
|
||||
if (Option.isSome(result)) return result.value
|
||||
return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
|
||||
})
|
||||
}
|
||||
|
||||
function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect<void, E>) {
|
||||
return Effect.gen(function* () {
|
||||
while (true) {
|
||||
const result = yield* maybeNextUpdate(check, trigger(), "250 millis")
|
||||
if (Option.isSome(result)) return result.value
|
||||
}
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function noUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>, timeout = 500) {
|
||||
return Effect.acquireUseRelease(
|
||||
wait(check),
|
||||
({ deferred }) =>
|
||||
trigger.pipe(
|
||||
Effect.andThen(Deferred.await(deferred)),
|
||||
Effect.timeoutOption(`${timeout} millis`),
|
||||
Effect.tap((result) => Effect.sync(() => expect(result).toEqual(Option.none()))),
|
||||
),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
}
|
||||
|
||||
function ready(directory: string) {
|
||||
const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* eventuallyUpdate(
|
||||
(event) => event.file === file,
|
||||
() => fs.writeFileString(file, `ready-${Math.random()}`),
|
||||
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
|
||||
})
|
||||
}
|
||||
|
||||
describeWatcher("Watcher", () => {
|
||||
it.live("publishes root create, update, and delete events", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const file = path.join(directory, "watch.txt")
|
||||
yield* ready(directory)
|
||||
for (const item of [
|
||||
{ event: "add" as const, trigger: fs.writeFileString(file, "a") },
|
||||
{ event: "change" as const, trigger: fs.writeFileString(file, "b") },
|
||||
{ event: "unlink" as const, trigger: fs.remove(file) },
|
||||
]) {
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === file && event.event === item.event, item.trigger),
|
||||
).toEqual({
|
||||
file,
|
||||
event: item.event,
|
||||
})
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches non-git roots", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const file = path.join(directory, "plain.txt")
|
||||
yield* ready(directory)
|
||||
expect(yield* nextUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))).toEqual({
|
||||
file,
|
||||
event: "add",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("cleanup stops publishing events", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* ready(tmp.path).pipe(provide(tmp.path), Effect.scoped)
|
||||
const file = path.join(tmp.path, "after-dispose.txt")
|
||||
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
)
|
||||
}).pipe(Effect.provide(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))),
|
||||
)
|
||||
|
||||
it.live("ignores .git/index changes", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const index = path.join(directory, ".git", "index")
|
||||
yield* ready(directory)
|
||||
yield* noUpdate(
|
||||
(event) => event.file === index,
|
||||
fs
|
||||
.writeFileString(path.join(directory, "tracked.txt"), "a")
|
||||
.pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("publishes .git/HEAD events", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const head = path.join(directory, ".git", "HEAD")
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
yield* ready(directory)
|
||||
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
|
||||
).toEqual({
|
||||
file: head,
|
||||
event: "change",
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
const describeSymlink = process.platform !== "win32" ? describe : describe.skip
|
||||
describeSymlink("symlinked .git", () => {
|
||||
it.live("publishes .git/HEAD events through a symlinked .git directory", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const afs = yield* FSUtil.Service
|
||||
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
|
||||
yield* ready(directory)
|
||||
const head = path.join(directory, ".git", "HEAD")
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||
expect(
|
||||
yield* nextUpdate(
|
||||
(event) => event.file === path.join(actual, "HEAD"),
|
||||
afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
|
||||
),
|
||||
).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
|
||||
}),
|
||||
{
|
||||
git: true,
|
||||
init: async (directory) => {
|
||||
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
|
||||
await fs.rename(path.join(directory, ".git"), actual)
|
||||
await fs.symlink(actual, path.join(directory, ".git"))
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
|
|||
log: os.tmpdir(),
|
||||
})
|
||||
|
||||
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
|
||||
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
async function job() {
|
||||
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
|
||||
|
|
|
|||
49
packages/core/test/fixture/git.ts
Normal file
49
packages/core/test/fixture/git.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { execFile } from "child_process"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { promisify } from "util"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
|
||||
const exec = promisify(execFile)
|
||||
|
||||
export async function gitRemote(root: string) {
|
||||
const origin = path.join(root, "origin.git")
|
||||
const source = path.join(root, "source")
|
||||
await git(root, "init", "--bare", origin)
|
||||
await git(root, "init", source)
|
||||
await git(source, "config", "user.email", "test@example.com")
|
||||
await git(source, "config", "user.name", "Test")
|
||||
await fs.writeFile(path.join(source, "README.md"), "one\n")
|
||||
await git(source, "add", "README.md")
|
||||
await git(source, "commit", "-m", "initial")
|
||||
await git(source, "branch", "-M", "main")
|
||||
await git(source, "remote", "add", "origin", pathToFileURL(origin).href)
|
||||
await git(source, "push", "-u", "origin", "main")
|
||||
await git(root, "--git-dir", origin, "symbolic-ref", "HEAD", "refs/heads/main")
|
||||
return {
|
||||
root,
|
||||
source,
|
||||
remote: pathToFileURL(origin).href,
|
||||
reference: { ...Repository.parseRemote("owner/repo"), remote: pathToFileURL(origin).href },
|
||||
}
|
||||
}
|
||||
|
||||
export async function commit(source: string, content: string, message: string) {
|
||||
await fs.writeFile(path.join(source, "README.md"), content)
|
||||
await git(source, "add", "README.md")
|
||||
await git(source, "commit", "-m", message)
|
||||
await git(source, "push")
|
||||
}
|
||||
|
||||
export async function branch(source: string, name: string, content: string) {
|
||||
await git(source, "checkout", "-b", name)
|
||||
await fs.writeFile(path.join(source, "README.md"), content)
|
||||
await git(source, "add", "README.md")
|
||||
await git(source, "commit", "-m", name)
|
||||
await git(source, "push", "-u", "origin", name)
|
||||
}
|
||||
|
||||
export async function git(cwd: string, ...args: string[]) {
|
||||
await exec("git", args, { cwd })
|
||||
}
|
||||
|
|
@ -3,11 +3,22 @@ import { tmpdir as osTmpdir } from "os"
|
|||
import path from "path"
|
||||
|
||||
export const tmpdir = async () => {
|
||||
const dir = await fs.mkdtemp(path.join(osTmpdir(), "opencode-core-test-"))
|
||||
const dir = await fs.realpath(await fs.mkdtemp(path.join(osTmpdir(), "opencode-core-test-")))
|
||||
return {
|
||||
path: dir,
|
||||
async [Symbol.asyncDispose]() {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
await remove(dir)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(dir: string, retries = 10): Promise<void> {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
if (retries === 0 || !error || typeof error !== "object" || !("code" in error) || error.code !== "EBUSY")
|
||||
throw error
|
||||
await Bun.sleep(100)
|
||||
return remove(dir, retries - 1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json
vendored
Normal file
27
packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"name": "session-runner/openai-chat-streams-text",
|
||||
"recordedAt": "2026-06-02T19:52:25.084Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"chatcmpl-DmPRwO9SjY0GJZ3TFFe1Be72wysEG\",\"object\":\"chat.completion.chunk\",\"created\":1780429944,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_40bf7dabb5\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"f3yrdno80\"}\n\ndata: {\"id\":\"chatcmpl-DmPRwO9SjY0GJZ3TFFe1Be72wysEG\",\"object\":\"chat.completion.chunk\",\"created\":1780429944,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_40bf7dabb5\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"fDsGzJ\"}\n\ndata: {\"id\":\"chatcmpl-DmPRwO9SjY0GJZ3TFFe1Be72wysEG\",\"object\":\"chat.completion.chunk\",\"created\":1780429944,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_40bf7dabb5\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"RqaP5kpPNU\"}\n\ndata: {\"id\":\"chatcmpl-DmPRwO9SjY0GJZ3TFFe1Be72wysEG\",\"object\":\"chat.completion.chunk\",\"created\":1780429944,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_40bf7dabb5\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"B19l5\"}\n\ndata: {\"id\":\"chatcmpl-DmPRwO9SjY0GJZ3TFFe1Be72wysEG\",\"object\":\"chat.completion.chunk\",\"created\":1780429944,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_40bf7dabb5\",\"choices\":[],\"usage\":{\"prompt_tokens\":22,\"completion_tokens\":2,\"total_tokens\":24,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"kbiJobM55YE\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
106
packages/core/test/git.test.ts
Normal file
106
packages/core/test/git.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { branch, commit, gitRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Git.defaultLayer)
|
||||
|
||||
describe("Git", () => {
|
||||
it.live("clones a remote and reads checkout metadata", () =>
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const git = yield* Git.Service
|
||||
const target = path.join(fixture.root, "checkout")
|
||||
const result = yield* git.clone({ remote: fixture.remote, target })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(yield* git.origin(target)).toBe(fixture.remote)
|
||||
expect(yield* git.head(target)).toBeString()
|
||||
expect(yield* git.branch(target)).toBe("main")
|
||||
expect(yield* git.remoteHead(target)).toBe("origin/main")
|
||||
expect(yield* read(path.join(target, "README.md"))).toBe("one\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("fetches, checks out, and resets remote changes", () =>
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const git = yield* Git.Service
|
||||
const target = path.join(fixture.root, "checkout")
|
||||
yield* git.clone({ remote: fixture.remote, target })
|
||||
|
||||
yield* Effect.promise(() => commit(fixture.source, "two\n", "second"))
|
||||
expect((yield* git.fetch(target)).exitCode).toBe(0)
|
||||
expect((yield* git.reset(target, "origin/main")).exitCode).toBe(0)
|
||||
expect(yield* read(path.join(target, "README.md"))).toBe("two\n")
|
||||
|
||||
yield* Effect.promise(() => branch(fixture.source, "feature/docs", "feature\n"))
|
||||
expect((yield* git.fetchBranch(target, "feature/docs")).exitCode).toBe(0)
|
||||
expect((yield* git.checkout(target, "feature/docs")).exitCode).toBe(0)
|
||||
expect((yield* git.reset(target, "origin/feature/docs")).exitCode).toBe(0)
|
||||
expect(yield* git.branch(target)).toBe("feature/docs")
|
||||
expect(yield* read(path.join(target, "README.md"))).toBe("feature\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.promise(async () => {
|
||||
const root = await tmpdir()
|
||||
return { root, fixture: await gitRemote(root.path) }
|
||||
}),
|
||||
(input) => body(input.fixture),
|
||||
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
|
||||
)
|
||||
}
|
||||
|
||||
function read(file: string) {
|
||||
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
|
||||
}
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(directory).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
|
||||
await $`git config user.name Test`.cwd(directory).quiet()
|
||||
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
|
||||
}
|
||||
|
||||
describe("Git worktrees", () => {
|
||||
it.live("creates, lists, and removes linked worktrees", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const directory = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const worktree = AbsolutePath.make(`${root.path}-git-worktree`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
const git = yield* Git.Service
|
||||
const repo = { directory, store: AbsolutePath.make(path.join(directory, ".git")) }
|
||||
|
||||
yield* git.worktreeCreate({ repo, directory: worktree })
|
||||
|
||||
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(true)
|
||||
const linked = yield* git.find(worktree)
|
||||
expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
|
||||
expect(linked?.store).toBe(repo.store)
|
||||
if (!linked) throw new Error("Linked worktree not found")
|
||||
yield* git.worktreeRemove({ repo: linked, directory: worktree })
|
||||
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
427
packages/core/test/location-filesystem.test.ts
Normal file
427
packages/core/test/location-filesystem.test.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Exit, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { location } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const inertReferences = ProjectReference.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
get: () => Effect.succeed(undefined),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath: () => Effect.void,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
})
|
||||
|
||||
function provide(directory: string, references = inertReferences, filesystem = FSUtil.defaultLayer) {
|
||||
return Effect.provide(
|
||||
FileSystem.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
filesystem,
|
||||
Ripgrep.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
Layer.succeed(ProjectReference.Service, references),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
}
|
||||
|
||||
describe("FileSystem", () => {
|
||||
it.live("reads text and binary files", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "hello.txt"), "hello"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
|
||||
const service = yield* FileSystem.Service
|
||||
|
||||
expect(yield* service.read({ path: RelativePath.make("hello.txt") })).toEqual({
|
||||
type: "text",
|
||||
content: "hello",
|
||||
mime: "text/plain",
|
||||
})
|
||||
expect(yield* service.read({ path: RelativePath.make("data.bin") })).toEqual({
|
||||
type: "binary",
|
||||
content: "AAEC",
|
||||
encoding: "base64",
|
||||
mime: "application/octet-stream",
|
||||
})
|
||||
const binary = yield* service.resolveRead({ path: RelativePath.make("data.bin") })
|
||||
expect(Exit.isFailure(yield* service.readTextPageResolved(binary).pipe(Effect.exit))).toBe(true)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("pages large UTF-8 text files by line with continuation", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const lines = Array.from({ length: 30 }, (_, index) => `line-${index + 1}-é`.padEnd(2_000, "x"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "large.txt"), lines.join("\n")))
|
||||
const service = yield* FileSystem.Service
|
||||
const target = yield* service.resolveRead({ path: RelativePath.make("large.txt") })
|
||||
|
||||
const first = yield* service.readTextPageResolved(target)
|
||||
expect(first).toMatchObject({
|
||||
type: "text-page",
|
||||
offset: 1,
|
||||
truncated: true,
|
||||
})
|
||||
expect(first.next).toBeDefined()
|
||||
const next = first.next!
|
||||
expect(yield* service.readTextPageResolved(target, { offset: next, limit: 1 })).toEqual({
|
||||
type: "text-page",
|
||||
content: lines[next - 1],
|
||||
mime: "text/plain",
|
||||
offset: next,
|
||||
truncated: true,
|
||||
next: next + 1,
|
||||
})
|
||||
expect(yield* service.readTextPageResolved(target, { offset: 30 })).toEqual({
|
||||
type: "text-page",
|
||||
content: lines[29],
|
||||
mime: "text/plain",
|
||||
offset: 30,
|
||||
truncated: false,
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("lists direct children with relative paths and resolved URIs", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
|
||||
const service = yield* FileSystem.Service
|
||||
|
||||
const entries = yield* service.list()
|
||||
expect(entries.map(({ uri: _uri, ...entry }) => entry)).toEqual([
|
||||
{
|
||||
path: RelativePath.make("src"),
|
||||
type: "directory",
|
||||
mime: "application/x-directory",
|
||||
},
|
||||
{
|
||||
path: RelativePath.make("README.md"),
|
||||
type: "file",
|
||||
mime: "text/markdown",
|
||||
},
|
||||
])
|
||||
expect(
|
||||
yield* Effect.promise(() => Promise.all(entries.map((entry) => fs.realpath(fileURLToPath(entry.uri))))),
|
||||
).toEqual(
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([fs.realpath(path.join(directory, "src")), fs.realpath(path.join(directory, "README.md"))]),
|
||||
),
|
||||
)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("lists stable bounded pages", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(directory, "src"))
|
||||
await fs.writeFile(path.join(directory, "README.md"), "# Test")
|
||||
})
|
||||
const service = yield* FileSystem.Service
|
||||
|
||||
expect(yield* service.listPage({ limit: 1 })).toMatchObject({
|
||||
entries: [{ path: "src", type: "directory" }],
|
||||
truncated: true,
|
||||
next: 2,
|
||||
})
|
||||
expect(yield* service.listPage({ offset: 2, limit: 1 })).toMatchObject({
|
||||
entries: [{ path: "README.md", type: "file" }],
|
||||
truncated: false,
|
||||
})
|
||||
expect((yield* service.resolveList()).resource).toBe(".")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("materializes only the selected direct children for a page", () =>
|
||||
withTmp((directory) => {
|
||||
const realPaths: string[] = []
|
||||
const filesystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...service,
|
||||
realPath: (target) =>
|
||||
Effect.sync(() => realPaths.push(target)).pipe(Effect.andThen(service.realPath(target))),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(directory, "src"))
|
||||
await fs.writeFile(path.join(directory, "alpha.txt"), "alpha")
|
||||
await fs.writeFile(path.join(directory, "beta.txt"), "beta")
|
||||
})
|
||||
const service = yield* FileSystem.Service
|
||||
|
||||
expect(yield* service.listPage({ offset: 2, limit: 1 })).toMatchObject({
|
||||
entries: [{ path: "alpha.txt", type: "file" }],
|
||||
truncated: true,
|
||||
next: 3,
|
||||
})
|
||||
expect(realPaths.filter((target) => target !== directory)).toEqual([path.join(directory, "alpha.txt")])
|
||||
}).pipe(provide(directory, inertReferences, filesystem))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("materializes selected page entries with at most 16 concurrent real path lookups", () =>
|
||||
withTmp((directory) => {
|
||||
let active = 0
|
||||
let maximum = 0
|
||||
const filesystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...service,
|
||||
realPath: (target) =>
|
||||
target === directory
|
||||
? service.realPath(target)
|
||||
: Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
active++
|
||||
maximum = Math.max(maximum, active)
|
||||
}),
|
||||
() => Effect.sleep("10 millis").pipe(Effect.andThen(service.realPath(target))),
|
||||
() => Effect.sync(() => active--),
|
||||
),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(Array.from({ length: 32 }, (_, index) => fs.writeFile(path.join(directory, `${index}.txt`), ""))),
|
||||
)
|
||||
const service = yield* FileSystem.Service
|
||||
|
||||
expect((yield* service.listPage({ limit: 32 })).entries).toHaveLength(32)
|
||||
expect(maximum).toBe(16)
|
||||
}).pipe(provide(directory, inertReferences, filesystem))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("caps direct list page service calls at 2000 entries", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
Array.from({ length: 2_001 }, (_, index) =>
|
||||
fs.writeFile(path.join(directory, `${index.toString().padStart(4, "0")}.txt`), ""),
|
||||
),
|
||||
),
|
||||
)
|
||||
const service = yield* FileSystem.Service
|
||||
const target = yield* service.resolveList()
|
||||
|
||||
expect((yield* service.listPageResolved(target, { limit: 2_001 })).entries).toHaveLength(2_000)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
test("rejects empty list aliases and page limits over 2000", () => {
|
||||
const decode = Schema.decodeUnknownSync(FileSystem.ListPageInput)
|
||||
expect(() => decode({ reference: "" })).toThrow()
|
||||
expect(() => decode({ limit: 2_001 })).toThrow()
|
||||
})
|
||||
|
||||
it.live("rejects escaping list paths and omits escaping symlink children", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const outside = `${directory}-outside`
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(outside)
|
||||
await fs.writeFile(path.join(outside, "secret.txt"), "secret")
|
||||
await fs.symlink(outside, path.join(directory, "escape"))
|
||||
})
|
||||
const service = yield* FileSystem.Service
|
||||
|
||||
expect(
|
||||
Exit.isFailure(yield* service.listPage({ path: RelativePath.make("../outside") }).pipe(Effect.exit)),
|
||||
).toBe(true)
|
||||
expect((yield* service.listPage()).entries).toEqual([])
|
||||
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("paginates visible entries after omitting escaping symlink children", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const outside = `${directory}-outside`
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(outside)
|
||||
await fs.symlink(outside, path.join(directory, "a-escape"))
|
||||
await fs.writeFile(path.join(directory, "b-visible.txt"), "visible")
|
||||
})
|
||||
const service = yield* FileSystem.Service
|
||||
|
||||
expect(yield* service.listPage({ limit: 1 })).toMatchObject({
|
||||
entries: [{ path: "b-visible.txt", type: "file" }],
|
||||
truncated: false,
|
||||
})
|
||||
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects paths outside the location", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* FileSystem.Service
|
||||
expect(
|
||||
Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
|
||||
).toBe(true)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reads and lists paths relative to a local project reference", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(path.join(docs, "README.md"), "docs")
|
||||
})
|
||||
const service = yield* FileSystem.Service
|
||||
|
||||
expect(yield* service.read({ reference: "docs", path: RelativePath.make("README.md") })).toMatchObject({
|
||||
type: "text",
|
||||
content: "docs",
|
||||
})
|
||||
expect(yield* service.list({ reference: "docs" })).toMatchObject([{ path: "README.md", type: "file" }])
|
||||
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("materializes Git references before filesystem access", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
const ensured: string[] = []
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(path.join(docs, "README.md"), "docs")
|
||||
})
|
||||
expect(
|
||||
yield* (yield* FileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }),
|
||||
).toMatchObject({ content: "docs" })
|
||||
expect(ensured).toEqual([docs])
|
||||
}).pipe(
|
||||
provide(
|
||||
directory,
|
||||
references(
|
||||
{
|
||||
sdk: {
|
||||
name: "sdk",
|
||||
kind: "git",
|
||||
repository: "owner/repo",
|
||||
reference: Repository.parseRemote("owner/repo"),
|
||||
path: docs,
|
||||
},
|
||||
},
|
||||
(target) => Effect.sync(() => ensured.push(target ?? "")),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects unknown, invalid, and escaping project reference paths", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(docs))
|
||||
const service = yield* FileSystem.Service
|
||||
expect(Exit.isFailure(yield* service.list({ reference: "unknown" }).pipe(Effect.exit))).toBe(true)
|
||||
expect(Exit.isFailure(yield* service.list({ reference: "invalid" }).pipe(Effect.exit))).toBe(true)
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* service.read({ reference: "docs", path: RelativePath.make("../outside") }).pipe(Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
}).pipe(
|
||||
provide(
|
||||
directory,
|
||||
references({
|
||||
docs: { name: "docs", kind: "local", path: docs },
|
||||
invalid: { name: "invalid", kind: "invalid", message: "invalid reference" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects aliases when project references are disabled", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
expect(Exit.isFailure(yield* (yield* FileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit))).toBe(
|
||||
true,
|
||||
)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects symlink escapes from project references", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
const outside = path.join(directory, "outside.txt")
|
||||
return Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(outside, "outside")
|
||||
await fs.symlink(outside, path.join(docs, "link.txt"))
|
||||
})
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* (yield* FileSystem.Service)
|
||||
.read({ reference: "docs", path: RelativePath.make("link.txt") })
|
||||
.pipe(Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function references(
|
||||
entries: Record<string, ProjectReference.Resolved>,
|
||||
ensurePath: ProjectReference.Interface["ensurePath"] = () => Effect.void,
|
||||
) {
|
||||
return ProjectReference.Service.of({
|
||||
list: () => Effect.succeed(Object.values(entries)),
|
||||
get: (name) => Effect.succeed(entries[name]),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
})
|
||||
}
|
||||
|
|
@ -9,13 +9,16 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
|||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { AppFileSystem } from "../src/filesystem"
|
||||
import { FSUtil } from "../src/fs-util"
|
||||
import { Auth } from "../src/auth"
|
||||
import { EventV2 } from "../src/event"
|
||||
import { Global } from "../src/global"
|
||||
import { ModelsDev } from "../src/models-dev"
|
||||
import { Npm } from "../src/npm"
|
||||
import { Project } from "../src/project"
|
||||
import { ProjectReference } from "../src/project-reference"
|
||||
import { LocationSearch } from "../src/location-search"
|
||||
import { ToolRegistry } from "../src/tool-registry"
|
||||
|
||||
const it = testEffect(
|
||||
LocationServiceMap.layer.pipe(
|
||||
|
|
@ -26,7 +29,7 @@ const it = testEffect(
|
|||
Auth.defaultLayer,
|
||||
Npm.defaultLayer,
|
||||
ModelsDev.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
Global.defaultLayer,
|
||||
),
|
||||
),
|
||||
|
|
@ -53,18 +56,49 @@ describe("LocationServiceMap", () => {
|
|||
const update = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
yield* PluginBoot.Service.use((boot) => boot.wait())
|
||||
yield* ProjectReference.Service
|
||||
yield* LocationSearch.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
return yield* catalog.provider.all()
|
||||
return {
|
||||
providers: yield* catalog.provider.all(),
|
||||
tools: yield* (yield* ToolRegistry.Service).definitions(),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) })))
|
||||
|
||||
expect((yield* update(blocked.path)).some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(
|
||||
false,
|
||||
)
|
||||
expect((yield* update(allowed.path)).some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(
|
||||
true,
|
||||
)
|
||||
const blockedState = yield* update(blocked.path)
|
||||
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false)
|
||||
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"apply_patch",
|
||||
"bash",
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"question",
|
||||
"read",
|
||||
"skill",
|
||||
"todowrite",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
"write",
|
||||
])
|
||||
const allowedState = yield* update(allowed.path)
|
||||
expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true)
|
||||
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"apply_patch",
|
||||
"bash",
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"question",
|
||||
"read",
|
||||
"skill",
|
||||
"todowrite",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
"write",
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
234
packages/core/test/location-mutation.test.ts
Normal file
234
packages/core/test/location-mutation.test.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { location } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
function provide(directory: string) {
|
||||
return Effect.provide(
|
||||
LocationMutation.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
}
|
||||
|
||||
describe("LocationMutation", () => {
|
||||
it.live("resolves an active relative existing file target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
|
||||
expect(plan.target).toMatchObject({
|
||||
canonical: yield* Effect.promise(() => fs.realpath(targetPath)),
|
||||
exists: true,
|
||||
resource: "hello.txt",
|
||||
})
|
||||
expect(plan.target.externalDirectory).toBeUndefined()
|
||||
expect(yield* (yield* LocationMutation.Service).revalidate(plan)).toMatchObject({
|
||||
canonical: plan.target.canonical,
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("resolves an active relative prospective file target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
||||
const root = yield* Effect.promise(() => fs.realpath(directory))
|
||||
|
||||
expect(plan.target).toMatchObject({
|
||||
canonical: path.join(root, "src", "new.txt"),
|
||||
exists: false,
|
||||
resource: "src/new.txt",
|
||||
})
|
||||
expect(plan.authority.canonical).toBe(path.join(root, "src"))
|
||||
expect(yield* (yield* LocationMutation.Service).revalidate(plan)).toMatchObject({
|
||||
canonical: plan.target.canonical,
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a relative lexical escape instead of promoting it to external authority", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Effect.flip((yield* LocationMutation.Service).resolve({ path: "../outside.txt" }))
|
||||
expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "relative_escape" })
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a prospective target below an escaping symlink ancestor", () =>
|
||||
withTmp((directory) => {
|
||||
const outside = `${directory}-outside`
|
||||
return Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(outside)
|
||||
await fs.symlink(outside, path.join(directory, "escape"))
|
||||
})
|
||||
const error = yield* Effect.flip(
|
||||
(yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") }),
|
||||
)
|
||||
expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "location_escape" })
|
||||
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
|
||||
}).pipe(provide(directory))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("accepts an explicit absolute in-location target without external approval", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "new.txt")
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
expect(plan.target).toMatchObject({
|
||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "new.txt"),
|
||||
resource: "new.txt",
|
||||
})
|
||||
expect(plan.target.externalDirectory).toBeUndefined()
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("requires external-directory authorization for an explicit external absolute target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new.txt")
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||
expect(plan.target).toMatchObject({
|
||||
canonical: path.join(root, "new.txt"),
|
||||
resource: path.join(root, "new.txt").replaceAll("\\", "/"),
|
||||
})
|
||||
expect(plan.target.externalDirectory).toMatchObject({
|
||||
directory: root,
|
||||
resource: path.join(root, "*").replaceAll("\\", "/"),
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("resolves an existing external file target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||
expect(plan.target).toMatchObject({ canonical: path.join(root, "existing.txt"), exists: true })
|
||||
expect(plan.authority.canonical).toBe(path.join(root, "existing.txt"))
|
||||
expect(plan.target.externalDirectory?.directory).toBe(root)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("anchors prospective external descendants at their stable existing directory", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
||||
const plan = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const root = yield* Effect.promise(() => fs.realpath(outside))
|
||||
expect(plan.authority.canonical).toBe(root)
|
||||
expect(plan.target.externalDirectory).toMatchObject({
|
||||
directory: root,
|
||||
resource: path.join(root, "*").replaceAll("\\", "/"),
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a symlink-ancestor swap during post-approval revalidation", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const parent = path.join(directory, "parent")
|
||||
yield* Effect.promise(() => fs.mkdir(parent))
|
||||
const service = yield* LocationMutation.Service
|
||||
const plan = yield* service.resolve({ path: path.join("parent", "new.txt") })
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.rmdir(parent)
|
||||
await fs.symlink(outside, parent)
|
||||
})
|
||||
|
||||
const error = yield* Effect.flip(service.revalidate(plan))
|
||||
expect(error).toMatchObject({ _tag: "LocationMutation.RevalidationError" })
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects an existing target identity swap during post-approval revalidation", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "first"))
|
||||
const service = yield* LocationMutation.Service
|
||||
const plan = yield* service.resolve({ path: "existing.txt" })
|
||||
yield* Effect.promise(async () => {
|
||||
const replacementPath = path.join(directory, "replacement.txt")
|
||||
await fs.writeFile(replacementPath, "second")
|
||||
await fs.rm(targetPath)
|
||||
await fs.rename(replacementPath, targetPath)
|
||||
})
|
||||
|
||||
const error = yield* Effect.flip(service.revalidate(plan))
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LocationMutation.RevalidationError",
|
||||
reason: "mutation authority changed",
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a nearer prospective ancestor introduced after approval", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* LocationMutation.Service
|
||||
const plan = yield* service.resolve({ path: path.join("new", "nested", "file.txt") })
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "new")))
|
||||
|
||||
const error = yield* Effect.flip(service.revalidate(plan))
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LocationMutation.RevalidationError",
|
||||
reason: "mutation authority changed",
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
test("keeps project references outside the mutation input API", () => {
|
||||
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
|
||||
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
||||
path: "README.md",
|
||||
})
|
||||
})
|
||||
})
|
||||
285
packages/core/test/location-search.test.ts
Normal file
285
packages/core/test/location-search.test.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { LocationSearch } from "@opencode-ai/core/location-search"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { location } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const inertReferences = references({})
|
||||
|
||||
function provide(directory: string, projectReferences = inertReferences) {
|
||||
const dependencies = Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
FileSystemRipgrep.defaultLayer,
|
||||
AppProcess.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
Layer.succeed(ProjectReference.Service, projectReferences),
|
||||
)
|
||||
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
|
||||
const search = LocationSearch.layer.pipe(
|
||||
Layer.provide(filesystem),
|
||||
Layer.provide(Ripgrep.layer.pipe(Layer.provide(dependencies))),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(dependencies),
|
||||
)
|
||||
return Effect.provide(Layer.merge(filesystem, search))
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
}
|
||||
|
||||
describe("LocationSearch", () => {
|
||||
it.live("searches files in the active Location with structured bounded results", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(directory, "src"))
|
||||
await fs.writeFile(path.join(directory, "src", "index.ts"), "export const value = 1\n")
|
||||
await fs.writeFile(path.join(directory, "notes.txt"), "notes\n")
|
||||
})
|
||||
const result = yield* (yield* LocationSearch.Service).files({ pattern: "*.ts" })
|
||||
const canonical = yield* Effect.promise(() => fs.realpath(path.join(directory, "src", "index.ts")))
|
||||
|
||||
expect(result).toMatchObject({ truncated: false, partial: false })
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]).toMatchObject({
|
||||
path: RelativePath.make("src/index.ts"),
|
||||
canonical,
|
||||
resource: "src/index.ts",
|
||||
})
|
||||
expect(typeof result.items[0].mtime).toBe("number")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("searches files under a relative subdirectory and named local reference", () =>
|
||||
withTmp((directory) => {
|
||||
const docs = path.join(directory, "docs")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(directory, "src"))
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(path.join(directory, "src", "active.ts"), "active\n")
|
||||
await fs.writeFile(path.join(docs, "guide.md"), "guide\n")
|
||||
})
|
||||
const search = yield* LocationSearch.Service
|
||||
|
||||
expect(
|
||||
(yield* search.files({ pattern: "*.ts", path: RelativePath.make("src") })).items.map((item) => item.path),
|
||||
).toEqual([RelativePath.make("src/active.ts")])
|
||||
const guide = yield* Effect.promise(() => fs.realpath(path.join(docs, "guide.md")))
|
||||
expect((yield* search.files({ pattern: "*.md", reference: "docs" })).items).toMatchObject([
|
||||
{ path: RelativePath.make("guide.md"), resource: "docs:guide.md", canonical: guide },
|
||||
])
|
||||
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("greps the Location, exact relative files and directories, and include globs", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(directory, "src"))
|
||||
await fs.writeFile(path.join(directory, "src", "one.ts"), "needle ts\n")
|
||||
await fs.writeFile(path.join(directory, "src", "two.txt"), "needle txt\n")
|
||||
await fs.writeFile(path.join(directory, "root.md"), "needle root\n")
|
||||
})
|
||||
const search = yield* LocationSearch.Service
|
||||
|
||||
expect((yield* search.grep({ pattern: "needle" })).items.map((item) => item.path).sort()).toEqual([
|
||||
RelativePath.make("root.md"),
|
||||
RelativePath.make("src/one.ts"),
|
||||
RelativePath.make("src/two.txt"),
|
||||
])
|
||||
expect(
|
||||
(yield* search.grep({ pattern: "needle", path: RelativePath.make("src") })).items
|
||||
.map((item) => item.path)
|
||||
.sort(),
|
||||
).toEqual([RelativePath.make("src/one.ts"), RelativePath.make("src/two.txt")])
|
||||
expect((yield* search.grep({ pattern: "needle", path: RelativePath.make("src/one.ts") })).items).toMatchObject([
|
||||
{ path: RelativePath.make("src/one.ts"), resource: "src/one.ts", lines: "needle ts\n", line: 1, offset: 0 },
|
||||
])
|
||||
expect((yield* search.grep({ pattern: "needle", include: "*.ts" })).items.map((item) => item.path)).toEqual([
|
||||
RelativePath.make("src/one.ts"),
|
||||
])
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not discover hidden files during broad V2 searches", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(directory, "nested", ".private"), { recursive: true })
|
||||
await fs.writeFile(path.join(directory, "visible.txt"), "needle visible\n")
|
||||
await fs.writeFile(path.join(directory, ".env"), "needle root secret\n")
|
||||
await fs.writeFile(path.join(directory, "nested", "visible.txt"), "needle nested visible\n")
|
||||
await fs.writeFile(path.join(directory, "nested", ".env"), "needle nested secret\n")
|
||||
await fs.writeFile(path.join(directory, "nested", ".private", "secret.txt"), "needle hidden directory\n")
|
||||
})
|
||||
const search = yield* LocationSearch.Service
|
||||
|
||||
expect((yield* search.files({ pattern: "*" })).items.map((item) => item.path).sort()).toEqual([
|
||||
RelativePath.make("nested/visible.txt"),
|
||||
RelativePath.make("visible.txt"),
|
||||
])
|
||||
expect((yield* search.files({ pattern: ".env" })).items).toEqual([])
|
||||
expect((yield* search.grep({ pattern: "needle", include: "*" })).items.map((item) => item.path).sort()).toEqual(
|
||||
[RelativePath.make("nested/visible.txt"), RelativePath.make("visible.txt")],
|
||||
)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("caps result counts and line previews", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await Promise.all(
|
||||
Array.from({ length: 101 }, (_, index) => fs.writeFile(path.join(directory, `${index}.txt`), "needle\n")),
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(directory, "long.txt"),
|
||||
`needle ${"x".repeat(LocationSearch.MAX_LINE_PREVIEW_LENGTH)}\n`,
|
||||
)
|
||||
})
|
||||
const search = yield* LocationSearch.Service
|
||||
const files = yield* search.files({ pattern: "*.txt", limit: 2 })
|
||||
const hardCappedFiles = yield* search.files({ pattern: "*.txt", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })
|
||||
const hardCappedGrep = yield* search.grep({ pattern: "needle", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })
|
||||
const grep = yield* search.grep({ pattern: "needle", path: RelativePath.make("long.txt") })
|
||||
|
||||
expect(files.items).toHaveLength(2)
|
||||
expect(files.truncated).toBe(true)
|
||||
expect(hardCappedFiles.items).toHaveLength(LocationSearch.MAX_RESULT_LIMIT)
|
||||
expect(hardCappedFiles.truncated).toBe(true)
|
||||
expect(hardCappedGrep.items).toHaveLength(LocationSearch.MAX_RESULT_LIMIT)
|
||||
expect(hardCappedGrep.truncated).toBe(true)
|
||||
expect(grep.items[0].lines).toHaveLength(LocationSearch.MAX_LINE_PREVIEW_LENGTH)
|
||||
expect(grep.items[0].linePreviewTruncated).toBe(true)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports invalid regex as a typed failure", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "notes.txt"), "notes\n"))
|
||||
const exit = yield* (yield* LocationSearch.Service).grep({ pattern: "[" }).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Ripgrep.InvalidPatternError)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects oversized ripgrep JSON records before durable projection", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "huge.txt"), `needle ${"x".repeat(Ripgrep.MAX_RECORD_BYTES)}\n`),
|
||||
)
|
||||
const exit = yield* (yield* LocationSearch.Service).grep({ pattern: "needle" }).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(String(Cause.squash(exit.cause))).toContain("Ripgrep JSON record exceeded")
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects lexical and symlink escapes through root resolution", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const outside = `${directory}-outside`
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(outside)
|
||||
await fs.writeFile(path.join(outside, "secret.txt"), "secret\n")
|
||||
await fs.symlink(outside, path.join(directory, "escape"))
|
||||
})
|
||||
const search = yield* LocationSearch.Service
|
||||
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* search.files({ pattern: "*", path: RelativePath.make("../outside") }).pipe(Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
Exit.isFailure(yield* search.files({ pattern: "*", path: RelativePath.make("escape") }).pipe(Effect.exit)),
|
||||
).toBe(true)
|
||||
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects an approved root swapped to a symlink before ripgrep traversal", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const source = path.join(directory, "src")
|
||||
const outside = `${directory}-outside`
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(source)
|
||||
await fs.mkdir(outside)
|
||||
await fs.writeFile(path.join(outside, "secret.txt"), "secret\n")
|
||||
})
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const approved = yield* filesystem.resolveRoot({ path: RelativePath.make("src") })
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.rmdir(source)
|
||||
await fs.symlink(outside, source)
|
||||
})
|
||||
|
||||
expect(
|
||||
Exit.isFailure(yield* (yield* LocationSearch.Service).files({ pattern: "*" }, approved).pipe(Effect.exit)),
|
||||
).toBe(true)
|
||||
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("honors a pre-aborted cancellation signal", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const exit = yield* (yield* LocationSearch.Service)
|
||||
.files({ pattern: "*", signal: controller.signal })
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
test("exposes schema-testable search bounds", () => {
|
||||
const decode = Schema.decodeUnknownSync(LocationSearch.FilesInput)
|
||||
expect(LocationSearch.DEFAULT_RESULT_LIMIT).toBe(100)
|
||||
expect(LocationSearch.MAX_RESULT_LIMIT).toBe(100)
|
||||
expect(LocationSearch.MAX_LINE_PREVIEW_LENGTH).toBe(2_000)
|
||||
expect(() => decode({ pattern: "*", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
function references(entries: Record<string, ProjectReference.Resolved>) {
|
||||
return ProjectReference.Service.of({
|
||||
list: () => Effect.succeed(Object.values(entries)),
|
||||
get: (name) => Effect.succeed(entries[name]),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath: () => Effect.void,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
})
|
||||
}
|
||||
|
|
@ -3,12 +3,15 @@ import { Effect, Layer } from "effect"
|
|||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: "workspace" }
|
||||
const workspaceID = WorkspaceV2.ID.make("wrk_test")
|
||||
const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID }
|
||||
const projectLayer = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
directories: () => Effect.succeed([]),
|
||||
resolve: () =>
|
||||
Effect.succeed({
|
||||
id: Project.ID.make("project"),
|
||||
|
|
@ -26,7 +29,7 @@ describe("Location", () => {
|
|||
const location = yield* Location.Service
|
||||
|
||||
expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app"))
|
||||
expect(location.workspaceID).toBe("workspace")
|
||||
expect(location.workspaceID).toBe(workspaceID)
|
||||
expect(location.project.id).toBe(Project.ID.make("project"))
|
||||
expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
|
||||
expect(location.vcs).toEqual({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
|
|
@ -92,7 +92,7 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
|
|||
// every test would reuse the cachedInvalidateWithTTL state from the first run.
|
||||
Layer.fresh(ModelsDev.layer).pipe(
|
||||
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import path from "path"
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
|
|
@ -23,7 +23,7 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
|
|||
const npmLayer = (cache: string) =>
|
||||
Npm.layer.pipe(
|
||||
Layer.provide(EffectFlock.layer),
|
||||
Layer.provide(AppFileSystem.layer),
|
||||
Layer.provide(FSUtil.layer),
|
||||
Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
|
|
|
|||
16
packages/core/test/opencode.test.ts
Normal file
16
packages/core/test/opencode.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode } from "@opencode-ai/core/opencode"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(OpenCode.layer)
|
||||
|
||||
describe("OpenCode.layer", () => {
|
||||
it.effect("exposes Sessions through the public embedded API", () =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.Service
|
||||
|
||||
expect(yield* opencode.sessions.list()).toBeArray()
|
||||
}),
|
||||
)
|
||||
})
|
||||
68
packages/core/test/patch.test.ts
Normal file
68
packages/core/test/patch.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Patch } from "@opencode-ai/core/patch"
|
||||
|
||||
describe("Patch", () => {
|
||||
test("parses add, update, and delete hunks", () => {
|
||||
expect(
|
||||
Patch.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([
|
||||
{ type: "add", path: "add.txt", contents: "added" },
|
||||
{
|
||||
type: "update",
|
||||
path: "update.txt",
|
||||
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: "section", endOfFile: undefined }],
|
||||
movePath: undefined,
|
||||
},
|
||||
{ type: "delete", path: "delete.txt" },
|
||||
])
|
||||
})
|
||||
|
||||
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([
|
||||
{ type: "add", path: "add.txt", contents: "added" },
|
||||
])
|
||||
})
|
||||
|
||||
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("matches EOF-anchored chunks from the end", () => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
"update.txt",
|
||||
[{ oldLines: ["marker", "end"], newLines: ["marker changed", "end"], endOfFile: true }],
|
||||
"marker\nmiddle\nmarker\nend\n",
|
||||
).content,
|
||||
).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("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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -12,6 +12,8 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
|
@ -22,13 +24,22 @@ const current = Layer.succeed(
|
|||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||
)
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const sessions = SessionV2.layer.pipe(Layer.provide(database))
|
||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(events),
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
)
|
||||
const saved = PermissionSaved.layer.pipe(Layer.provide(database))
|
||||
const layer = PermissionV2.locationLayer.pipe(
|
||||
Layer.provideMerge(database),
|
||||
Layer.provideMerge(store),
|
||||
Layer.provideMerge(events),
|
||||
Layer.provideMerge(current),
|
||||
Layer.provideMerge(sessions),
|
||||
Layer.provideMerge(SessionExecution.noopLayer),
|
||||
Layer.provideMerge(saved),
|
||||
)
|
||||
const it = testEffect(layer)
|
||||
|
|
@ -127,6 +138,67 @@ describe("PermissionV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("uses build permissions when the Session agent is omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ agent: null })
|
||||
.where(eq(SessionTable.id, SessionV2.ID.make("ses_test")))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const agents = yield* AgentV2.Service
|
||||
const update = yield* agents.transform()
|
||||
yield* update((editor) =>
|
||||
editor.update(AgentV2.ID.make("build"), (agent) => {
|
||||
agent.permissions = [{ action: "todowrite", resource: "*", effect: "allow" }]
|
||||
}),
|
||||
)
|
||||
|
||||
const service = yield* PermissionV2.Service
|
||||
expect(yield* service.ask(assertion({ action: "todowrite", resources: ["*"] }))).toEqual({
|
||||
id: PermissionV2.ID.create("per_test"),
|
||||
effect: "allow",
|
||||
})
|
||||
expect(yield* service.list()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("evaluates bash with the normal configured-rule semantics", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
|
||||
const service = yield* PermissionV2.Service
|
||||
const bash = assertion({ action: "bash", resources: ["pwd"] })
|
||||
expect(yield* service.ask(bash)).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow" })
|
||||
|
||||
yield* setRules([])
|
||||
expect(yield* service.ask(bash)).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "ask" })
|
||||
expect(yield* service.get(PermissionV2.ID.create("per_test"))).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const saved = yield* PermissionSaved.Service
|
||||
yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] })
|
||||
|
||||
const service = yield* PermissionV2.Service
|
||||
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toEqual({
|
||||
id: PermissionV2.ID.create("per_test"),
|
||||
effect: "allow",
|
||||
})
|
||||
expect(yield* service.list()).toEqual([])
|
||||
|
||||
yield* setRules([{ action: "bash", resource: "*", effect: "deny" }])
|
||||
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toEqual({
|
||||
id: PermissionV2.ID.create("per_test"),
|
||||
effect: "deny",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves an asked permission once", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
|
|
|
|||
90
packages/core/test/plugin.test.ts
Normal file
90
packages/core/test/plugin.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const events = Layer.mock(EventV2.Service)({
|
||||
publish: (definition, data) =>
|
||||
Effect.succeed({
|
||||
id: EventV2.ID.make("evt_plugin_test"),
|
||||
type: definition.type,
|
||||
data,
|
||||
}),
|
||||
})
|
||||
const plugins = PluginV2.layer.pipe(Layer.provide(events))
|
||||
|
||||
function state() {
|
||||
return State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
editor: (draft) => ({
|
||||
add: (value: string) => draft.values.push(value),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
describe("PluginV2", () => {
|
||||
it.effect("closes plugin-owned scopes when the registry layer finalizes", () =>
|
||||
Effect.gen(function* () {
|
||||
const values = state()
|
||||
const layerScope = yield* Scope.fork(yield* Scope.Scope)
|
||||
const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service)
|
||||
|
||||
yield* plugin.add({
|
||||
id: PluginV2.ID.make("scoped"),
|
||||
effect: Effect.gen(function* () {
|
||||
const transform = yield* values.transform()
|
||||
yield* transform((editor) => editor.add("scoped"))
|
||||
}),
|
||||
})
|
||||
expect(values.get().values).toEqual(["scoped"])
|
||||
|
||||
yield* Scope.close(layerScope, Exit.void)
|
||||
expect(values.get().values).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("serializes same-ID additions and leaves one removable contribution", () =>
|
||||
Effect.gen(function* () {
|
||||
const values = state()
|
||||
const layerScope = yield* Scope.fork(yield* Scope.Scope)
|
||||
const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service)
|
||||
const id = PluginV2.ID.make("shared")
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
|
||||
const first = yield* plugin
|
||||
.add({
|
||||
id,
|
||||
effect: Effect.gen(function* () {
|
||||
const transform = yield* values.transform()
|
||||
yield* transform((editor) => editor.add("first"))
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(releaseFirst)
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
|
||||
const second = yield* plugin
|
||||
.add({
|
||||
id,
|
||||
effect: Effect.gen(function* () {
|
||||
const transform = yield* values.transform()
|
||||
yield* transform((editor) => editor.add("second"))
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(values.get().values).toEqual(["first"])
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(values.get().values).toEqual(["second"])
|
||||
|
||||
yield* plugin.remove(id)
|
||||
expect(values.get().values).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
44
packages/core/test/plugin/command.test.ts
Normal file
44
packages/core/test/plugin/command.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const directory = AbsolutePath.make("/repo/packages/app")
|
||||
const project = AbsolutePath.make("/repo")
|
||||
const it = testEffect(
|
||||
CommandV2.locationLayer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
describe("CommandPlugin.Plugin", () => {
|
||||
it.effect("registers built-in init and review commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* CommandV2.Service
|
||||
yield* CommandPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(CommandV2.Service, command),
|
||||
Effect.provideService(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory }, { projectDirectory: project })),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* command.get("init")).toMatchObject({
|
||||
name: "init",
|
||||
description: "guided AGENTS.md setup",
|
||||
})
|
||||
expect((yield* command.get("init"))?.template).toContain("`/repo`")
|
||||
expect(yield* command.get("review")).toMatchObject({
|
||||
name: "review",
|
||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||
subtask: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -53,13 +53,13 @@ describe("AlibabaPlugin", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the old default languageModel(apiID) behavior", () =>
|
||||
it.effect("uses the old default languageModel(api.id) behavior", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(AlibabaPlugin)
|
||||
const item = model("alibaba", "alias", { apiID: ModelV2.ID.make("qwen-plus") })
|
||||
const item = model("alibaba", "alias", { api: { id: ModelV2.ID.make("qwen-plus") } })
|
||||
const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {})
|
||||
const language = result.sdk?.languageModel(item.apiID)
|
||||
const language = result.sdk?.languageModel(item.api.id)
|
||||
expect(language?.modelId).toBe("qwen-plus")
|
||||
expect(language?.provider).toBe("alibaba.chat")
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -18,8 +18,15 @@ function bedrockFetch(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
|
|||
).config.fetch
|
||||
}
|
||||
|
||||
function openAIUrl(language: unknown, path: string, modelId: string) {
|
||||
return (language as { config: { url: (input: { path: string; modelId: string }) => string } }).config.url({
|
||||
path,
|
||||
modelId,
|
||||
})
|
||||
}
|
||||
|
||||
describe("AmazonBedrockPlugin", () => {
|
||||
it.effect("moves endpoint option to endpoint URL", () =>
|
||||
it.effect("moves endpoint option to api URL", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
|
|
@ -27,25 +34,24 @@ describe("AmazonBedrockPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const bedrock = provider("amazon-bedrock", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
|
||||
options: {
|
||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} },
|
||||
body: { endpoint: "https://bedrock.example" },
|
||||
},
|
||||
})
|
||||
catalog.provider.update(bedrock.id, (item) => {
|
||||
item.endpoint = bedrock.endpoint
|
||||
item.options = bedrock.options
|
||||
item.api = bedrock.api
|
||||
item.request = bedrock.request
|
||||
})
|
||||
})
|
||||
const result = yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)
|
||||
expect(result.endpoint).toEqual({
|
||||
expect(result.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/amazon-bedrock",
|
||||
url: "https://bedrock.example",
|
||||
})
|
||||
expect(result.options.aisdk.provider.endpoint).toBeUndefined()
|
||||
expect(result.request.body.endpoint).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -243,6 +249,85 @@ describe("AmazonBedrockPlugin", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.effect("creates Mantle SDK with GPT-5 OpenAI base path", () =>
|
||||
withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(AmazonBedrockPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("amazon-bedrock", "openai.gpt-5.5", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock/mantle",
|
||||
options: {
|
||||
name: "amazon-bedrock",
|
||||
bearerToken: "token",
|
||||
baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1",
|
||||
region: "us-east-2",
|
||||
},
|
||||
},
|
||||
{},
|
||||
)
|
||||
const language = result.sdk.responses("openai.gpt-5.5")
|
||||
expect(openAIUrl(language, "/responses", "openai.gpt-5.5")).toBe(
|
||||
"https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("selects Mantle APIs without Bedrock cross-region prefixes", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
yield* plugin.add(AmazonBedrockPlugin)
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("amazon-bedrock", "openai.gpt-5.5", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("amazon-bedrock", "openai.gpt-oss-safeguard-120b", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/mantle" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { region: "us-east-1" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(calls).toEqual(["responses:openai.gpt-5.5", "chat:openai.gpt-oss-safeguard-120b"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores other Bedrock provider subpaths", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* plugin.add(AmazonBedrockPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5", {
|
||||
api: { type: "aisdk", package: "@ai-sdk/amazon-bedrock/anthropic" },
|
||||
}),
|
||||
package: "@ai-sdk/amazon-bedrock/anthropic",
|
||||
options: { name: "amazon-bedrock" },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(result.sdk).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses SigV4 credential env when bearer token is absent", () =>
|
||||
withEnv(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,18 +15,18 @@ describe("AnthropicPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("anthropic", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/anthropic" },
|
||||
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
api: { type: "aisdk", package: "@ai-sdk/anthropic" },
|
||||
request: { headers: { Existing: "1" }, body: {} },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
draft.options = item.options
|
||||
draft.api = item.api
|
||||
draft.request = item.request
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).options.headers["anthropic-beta"]).toBe(
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers["anthropic-beta"]).toBe(
|
||||
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
||||
)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).options.headers.Existing).toBe("1")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).request.headers.Existing).toBe("1")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ describe("AnthropicPlugin", () => {
|
|||
yield* plugin.add(AnthropicPlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => catalog.provider.update(provider("openai").id, () => {}))
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.headers["anthropic-beta"]).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.headers["anthropic-beta"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,17 +16,17 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
|
||||
item.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
item.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
})
|
||||
})
|
||||
const result = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))
|
||||
expect(result.endpoint).toEqual({
|
||||
expect(result.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://cognitive.cognitiveservices.azure.com/openai",
|
||||
})
|
||||
expect(result.options.aisdk.provider.baseURL).toBeUndefined()
|
||||
expect(result.options.aisdk.provider.resourceName).toBeUndefined()
|
||||
expect(result.request.body.baseURL).toBeUndefined()
|
||||
expect(result.request.body.resourceName).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -40,22 +40,22 @@ describe("AzureCognitiveServicesPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const azure = provider("azure-cognitive-services", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
|
||||
})
|
||||
const openai = provider("openai")
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.endpoint = azure.endpoint
|
||||
item.api = azure.api
|
||||
})
|
||||
catalog.provider.update(openai.id, (item) => {
|
||||
item.endpoint = openai.endpoint
|
||||
item.api = openai.api
|
||||
})
|
||||
})
|
||||
const azure = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))
|
||||
const openai = yield* catalog.provider.get(ProviderV2.ID.openai)
|
||||
expect(azure.options.aisdk.provider.baseURL).toBeUndefined()
|
||||
expect(azure.endpoint).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible" })
|
||||
expect(openai.options.aisdk.provider.baseURL).toBeUndefined()
|
||||
expect(openai.endpoint).toEqual({ type: "aisdk", package: "test-provider" })
|
||||
expect(azure.request.body.baseURL).toBeUndefined()
|
||||
expect(azure.api).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible" })
|
||||
expect(openai.request.body.baseURL).toBeUndefined()
|
||||
expect(openai.api).toEqual({ type: "aisdk", package: "test-provider" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@ describe("AzurePlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.azure, (item) => {
|
||||
item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
|
||||
item.api = { type: "aisdk", package: "@ai-sdk/azure" }
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -51,19 +51,17 @@ describe("AzurePlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const azure = provider("azure", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } },
|
||||
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
request: { headers: {}, body: { resourceName: "from-config" } },
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.endpoint = azure.endpoint
|
||||
item.options = azure.options
|
||||
item.api = azure.api
|
||||
item.request = azure.request
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe(
|
||||
"from-config",
|
||||
)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.aisdk.provider.resourceName).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-config")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.resourceName).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -100,12 +98,10 @@ describe("AzurePlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.azure, (item) => {
|
||||
item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
|
||||
item.api = { type: "aisdk", package: "@ai-sdk/azure" }
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe(
|
||||
"from-account",
|
||||
)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-account")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -119,15 +115,15 @@ describe("AzurePlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const azure = provider("azure", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } },
|
||||
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
request: { headers: {}, body: { resourceName: "" } },
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.endpoint = azure.endpoint
|
||||
item.options = azure.options
|
||||
item.api = azure.api
|
||||
item.request = azure.request
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -141,15 +137,15 @@ describe("AzurePlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const azure = provider("azure", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } },
|
||||
api: { type: "aisdk", package: "@ai-sdk/azure" },
|
||||
request: { headers: {}, body: { resourceName: " " } },
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.endpoint = azure.endpoint
|
||||
item.options = azure.options
|
||||
item.api = azure.api
|
||||
item.request = azure.request
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-env")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -227,7 +223,7 @@ describe("AzurePlugin", () => {
|
|||
"aisdk.language",
|
||||
{
|
||||
model: model("azure", "deployment", {
|
||||
options: { headers: {}, body: {}, aisdk: { provider: {}, request: { useCompletionUrls: true } } },
|
||||
request: { headers: {}, body: { useCompletionUrls: true } },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ describe("CerebrasPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
|
||||
item.endpoint = { type: "aisdk", package: "@ai-sdk/cerebras" }
|
||||
item.options.headers.Existing = "1"
|
||||
item.api = { type: "aisdk", package: "@ai-sdk/cerebras" }
|
||||
item.request.headers.Existing = "1"
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).options.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).request.headers).toEqual({
|
||||
Existing: "1",
|
||||
"X-Cerebras-3rd-Party-Integration": "opencode",
|
||||
})
|
||||
|
|
@ -45,7 +45,7 @@ describe("CerebrasPlugin", () => {
|
|||
yield* plugin.add(CerebrasPlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).options.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -54,20 +54,20 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "test-provider" }
|
||||
provider.api = { type: "aisdk", package: "test-provider" }
|
||||
}),
|
||||
)
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))
|
||||
const sdk = yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-workers-ai", "@cf/model", { endpoint: provider.endpoint }),
|
||||
model: model("cloudflare-workers-ai", "@cf/model", { api: provider.api }),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(provider.endpoint).toEqual({
|
||||
expect(provider.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1",
|
||||
|
|
@ -86,10 +86,10 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
|
||||
provider.api = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
|
||||
}),
|
||||
)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
url: "https://proxy.example/v1",
|
||||
|
|
@ -107,7 +107,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-workers-ai", "@cf/model", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" },
|
||||
|
|
@ -152,10 +152,10 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "test-provider" }
|
||||
provider.api = { type: "aisdk", package: "test-provider" }
|
||||
}),
|
||||
)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
url: "https://api.cloudflare.com/client/v4/accounts/account-acct/ai/v1",
|
||||
|
|
@ -173,11 +173,11 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "test-provider" }
|
||||
provider.options.aisdk.provider.accountId = "configured-acct"
|
||||
provider.api = { type: "aisdk", package: "test-provider" }
|
||||
provider.request.body.accountId = "configured-acct"
|
||||
}),
|
||||
)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1",
|
||||
|
|
@ -195,7 +195,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-workers-ai", "@cf/model", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://proxy.example/v1" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
|
|
@ -224,7 +224,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-workers-ai", "@cf/model", {
|
||||
endpoint: {
|
||||
api: {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||
|
|
@ -253,7 +253,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("cloudflare-workers-ai", "alias", { apiID: ModelV2.ID.make("@cf/api-model") }),
|
||||
model: model("cloudflare-workers-ai", "alias", { api: { id: ModelV2.ID.make("@cf/api-model") } }),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
|
|
@ -273,7 +273,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
|||
"aisdk.sdk",
|
||||
{
|
||||
model: model("cloudflare-workers-ai", "@cf/model", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://proxy.example/v1" },
|
||||
}),
|
||||
package: "@ai-sdk/anthropic",
|
||||
options: { name: "cloudflare-workers-ai" },
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ describe("CoherePlugin", () => {
|
|||
yield* plugin.add(CoherePlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("cohere", "alias", { apiID: ModelV2.ID.make("command-r-plus") }), sdk, options: {} },
|
||||
{ model: model("cohere", "alias", { api: { id: ModelV2.ID.make("command-r-plus") } }), sdk, options: {} },
|
||||
{},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ describe("DeepInfraPlugin", () => {
|
|||
yield* plugin.add(DeepInfraPlugin)
|
||||
const language = yield* aisdk.language(
|
||||
model("deepinfra", "meta-llama/Llama-3.3-70B-Instruct", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/deepinfra" },
|
||||
}),
|
||||
)
|
||||
expect(language.provider).toBe("deepinfra.chat")
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ describe("DynamicProviderPlugin", () => {
|
|||
const aisdk = yield* AISDK.Service
|
||||
yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.none<string>())))
|
||||
const exit = yield* aisdk
|
||||
.language(model("missing-entrypoint", "alias", { endpoint: { type: "aisdk", package: "fixture-provider" } }))
|
||||
.language(model("missing-entrypoint", "alias", { api: { type: "aisdk", package: "fixture-provider" } }))
|
||||
.pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
|
||||
|
|
@ -136,7 +136,7 @@ describe("DynamicProviderPlugin", () => {
|
|||
yield* plugin.add(dynamicPlugin())
|
||||
const exit = yield* aisdk
|
||||
.language(
|
||||
model("bad-import", "alias", { endpoint: { type: "aisdk", package: "file:///missing/provider-factory.js" } }),
|
||||
model("bad-import", "alias", { api: { type: "aisdk", package: "file:///missing/provider-factory.js" } }),
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
|
|
@ -151,22 +151,21 @@ describe("DynamicProviderPlugin", () => {
|
|||
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
|
||||
yield* plugin.add(dynamicPlugin(npmEntrypointLayer(Option.some(tmp.entrypoint))))
|
||||
const exit = yield* aisdk
|
||||
.language(model("missing-factory", "alias", { endpoint: { type: "aisdk", package: "fixture-provider" } }))
|
||||
.language(model("missing-factory", "alias", { api: { type: "aisdk", package: "fixture-provider" } }))
|
||||
.pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
|
||||
}),
|
||||
)
|
||||
|
||||
itWithAISDK.effect("uses the model apiID for the default language model", () =>
|
||||
itWithAISDK.effect("uses the model api.id for the default language model", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* plugin.add(dynamicPlugin())
|
||||
const language = yield* aisdk.language(
|
||||
model("custom", "alias", {
|
||||
apiID: ModelV2.ID.make("test-model-api"),
|
||||
endpoint: { type: "aisdk", package: fixtureProvider },
|
||||
api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider },
|
||||
}),
|
||||
)
|
||||
expect(language).toMatchObject({ modelID: "test-model-api", options: { name: "custom" } })
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ describe("GithubCopilotPlugin", () => {
|
|||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("github-copilot", "alias", { apiID: ModelV2.ID.make("claude-sonnet-4") }),
|
||||
model: model("github-copilot", "alias", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
},
|
||||
|
|
@ -119,7 +119,7 @@ describe("GithubCopilotPlugin", () => {
|
|||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("github-copilot", "default", { apiID: ModelV2.ID.make("gpt-5") }),
|
||||
model: model("github-copilot", "default", { api: { id: ModelV2.ID.make("gpt-5") } }),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
|
|
@ -128,7 +128,7 @@ describe("GithubCopilotPlugin", () => {
|
|||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("github-copilot", "small", { apiID: ModelV2.ID.make("gpt-5-mini") }),
|
||||
model: model("github-copilot", "small", { api: { id: ModelV2.ID.make("gpt-5-mini") } }),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
|
|
@ -137,7 +137,7 @@ describe("GithubCopilotPlugin", () => {
|
|||
yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("github-copilot", "sonnet", { apiID: ModelV2.ID.make("claude-sonnet-4") }),
|
||||
model: model("github-copilot", "sonnet", { api: { id: ModelV2.ID.make("claude-sonnet-4") } }),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ describe("GitLabPlugin", () => {
|
|||
{
|
||||
model: model("gitlab", "claude"),
|
||||
package: "gitlab-ai-provider",
|
||||
options: provider.options.aisdk.provider,
|
||||
options: provider.request.body,
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
|
@ -238,7 +238,7 @@ describe("GitLabPlugin", () => {
|
|||
{
|
||||
model: model("gitlab", "claude"),
|
||||
package: "gitlab-ai-provider",
|
||||
options: provider.options.aisdk.provider,
|
||||
options: provider.request.body,
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
|
@ -256,10 +256,9 @@ describe("GitLabPlugin", () => {
|
|||
"aisdk.language",
|
||||
{
|
||||
model: model("gitlab", "duo-workflow-custom", {
|
||||
options: {
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: { provider: {}, request: { workflowRef: "ref", workflowDefinition: "definition" } },
|
||||
body: { workflowRef: "ref", workflowDefinition: "definition" },
|
||||
},
|
||||
}),
|
||||
sdk: {
|
||||
|
|
@ -320,10 +319,9 @@ describe("GitLabPlugin", () => {
|
|||
"aisdk.language",
|
||||
{
|
||||
model: model("gitlab", "duo-workflow-custom", {
|
||||
options: {
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: { provider: {}, request: { featureFlags: { request_flag: true } } },
|
||||
body: { featureFlags: { request_flag: true } },
|
||||
},
|
||||
}),
|
||||
sdk: {
|
||||
|
|
@ -350,7 +348,7 @@ describe("GitLabPlugin", () => {
|
|||
"aisdk.language",
|
||||
{
|
||||
model: model("gitlab", "claude", {
|
||||
options: { headers: { h: "v" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
request: { headers: { h: "v" }, body: {} },
|
||||
}),
|
||||
sdk: {
|
||||
workflowChat: () => undefined,
|
||||
|
|
|
|||
|
|
@ -25,12 +25,12 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
||||
}),
|
||||
)
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))
|
||||
expect(provider.options.aisdk.provider.project).toBe("cloud-project")
|
||||
expect(provider.options.aisdk.provider.location).toBe("cloud-location")
|
||||
expect(provider.request.body.project).toBe("cloud-project")
|
||||
expect(provider.request.body.location).toBe("cloud-location")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -44,14 +44,14 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
||||
provider.options.aisdk.provider.project = "configured-project"
|
||||
provider.options.aisdk.provider.location = "configured-location"
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
|
||||
provider.request.body.project = "configured-project"
|
||||
provider.request.body.location = "configured-location"
|
||||
}),
|
||||
)
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))
|
||||
expect(provider.options.aisdk.provider.project).toBe("configured-project")
|
||||
expect(provider.options.aisdk.provider.location).toBe("configured-location")
|
||||
expect(provider.request.body.project).toBe("configured-project")
|
||||
expect(provider.request.body.location).toBe("configured-location")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ describe("GoogleVertexPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
|
|
@ -61,9 +61,9 @@ describe("GoogleVertexPlugin", () => {
|
|||
}),
|
||||
)
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
|
||||
expect(provider.options.aisdk.provider.project).toBe("google-cloud-project")
|
||||
expect(provider.options.aisdk.provider.location).toBe("google-vertex-location")
|
||||
expect(provider.endpoint).toEqual({
|
||||
expect(provider.request.body.project).toBe("google-cloud-project")
|
||||
expect(provider.request.body.location).toBe("google-vertex-location")
|
||||
expect(provider.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location",
|
||||
|
|
@ -92,7 +92,7 @@ describe("GoogleVertexPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
|
|
@ -104,7 +104,7 @@ describe("GoogleVertexPlugin", () => {
|
|||
"aisdk.sdk",
|
||||
{
|
||||
model: model("google-vertex", "gemini", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/google-vertex" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/google-vertex" },
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex",
|
||||
options: { name: "google-vertex" },
|
||||
|
|
@ -112,8 +112,8 @@ describe("GoogleVertexPlugin", () => {
|
|||
{},
|
||||
)
|
||||
|
||||
expect(provider.options.aisdk.provider.project).toBe("vertex-project")
|
||||
expect(provider.endpoint).toEqual({
|
||||
expect(provider.request.body.project).toBe("vertex-project")
|
||||
expect(provider.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4",
|
||||
|
|
@ -142,19 +142,19 @@ describe("GoogleVertexPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
}
|
||||
provider.options.aisdk.provider.project = "config-project"
|
||||
provider.options.aisdk.provider.location = "global"
|
||||
provider.request.body.project = "config-project"
|
||||
provider.request.body.location = "global"
|
||||
}),
|
||||
)
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
|
||||
expect(provider.options.aisdk.provider.project).toBe("config-project")
|
||||
expect(provider.options.aisdk.provider.location).toBe("global")
|
||||
expect(provider.endpoint).toEqual({
|
||||
expect(provider.request.body.project).toBe("config-project")
|
||||
expect(provider.request.body.location).toBe("global")
|
||||
expect(provider.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global",
|
||||
|
|
@ -171,17 +171,17 @@ describe("GoogleVertexPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = {
|
||||
provider.api = {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
|
||||
}
|
||||
provider.options.aisdk.provider.project = "config-project"
|
||||
provider.options.aisdk.provider.location = "eu"
|
||||
provider.request.body.project = "config-project"
|
||||
provider.request.body.location = "eu"
|
||||
}),
|
||||
)
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
|
||||
expect(provider.endpoint).toEqual({
|
||||
expect(provider.api).toEqual({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu",
|
||||
|
|
@ -207,13 +207,13 @@ describe("GoogleVertexPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
|
||||
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex" }
|
||||
provider.options.aisdk.provider.project = "config-project"
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/google-vertex" }
|
||||
provider.request.body.project = "config-project"
|
||||
}),
|
||||
)
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
|
||||
expect(provider.options.aisdk.provider.project).toBe("config-project")
|
||||
expect(provider.options.aisdk.provider.location).toBe("us-central1")
|
||||
expect(provider.request.body.project).toBe("config-project")
|
||||
expect(provider.request.body.location).toBe("us-central1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -233,7 +233,7 @@ describe("GoogleVertexPlugin", () => {
|
|||
"aisdk.sdk",
|
||||
{
|
||||
model: model("google-vertex", "gemini", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/google-vertex" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/google-vertex" },
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex",
|
||||
options: { name: "google-vertex" },
|
||||
|
|
@ -283,7 +283,7 @@ describe("GoogleVertexPlugin", () => {
|
|||
"aisdk.sdk",
|
||||
{
|
||||
model: model("google-vertex", "gemini", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "google-vertex" },
|
||||
|
|
|
|||
|
|
@ -51,18 +51,14 @@ describe("GooglePlugin", () => {
|
|||
yield* plugin.add(GooglePlugin)
|
||||
const language = yield* aisdk.language(
|
||||
model("custom-google", "alias", {
|
||||
apiID: ModelV2.ID.make("gemini-api"),
|
||||
endpoint: {
|
||||
api: {
|
||||
id: ModelV2.ID.make("gemini-api"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/google",
|
||||
},
|
||||
options: {
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: { apiKey: "test" },
|
||||
request: {},
|
||||
},
|
||||
body: { apiKey: "test" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -75,25 +75,21 @@ describe("GroqPlugin", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
aisdkIt.effect("uses the default languageModel(apiID) behavior", () =>
|
||||
aisdkIt.effect("uses the default languageModel(api.id) behavior", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* plugin.add(GroqPlugin)
|
||||
const result = yield* aisdk.language(
|
||||
model("groq", "alias", {
|
||||
apiID: ModelV2.ID.make("llama-api"),
|
||||
endpoint: {
|
||||
api: {
|
||||
id: ModelV2.ID.make("llama-api"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/groq",
|
||||
},
|
||||
options: {
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: { apiKey: "test" },
|
||||
request: {},
|
||||
},
|
||||
body: { apiKey: "test" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -54,43 +54,49 @@ export const it = testEffect(
|
|||
),
|
||||
)
|
||||
|
||||
export function provider(providerID: string, options?: Partial<ProviderV2.Info>) {
|
||||
type ProviderInput = Partial<Omit<ProviderV2.Info, "api" | "request">> & {
|
||||
api?: ProviderV2.Api
|
||||
request?: ProviderV2.Request
|
||||
}
|
||||
|
||||
type ModelInput = Partial<Omit<ModelV2.Info, "api" | "request">> & {
|
||||
api?: (ProviderV2.Api & { id?: ModelV2.ID }) | { id: ModelV2.ID }
|
||||
request?: ModelV2.Info["request"]
|
||||
}
|
||||
|
||||
export function provider(providerID: string, options?: ProviderInput) {
|
||||
return new ProviderV2.Info({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.make(providerID)),
|
||||
endpoint: {
|
||||
api: options?.api ?? {
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
},
|
||||
...options,
|
||||
options: {
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
...options?.options,
|
||||
...options?.request,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function model(providerID: string, modelID: string, options?: Partial<ModelV2.Info>) {
|
||||
export function model(providerID: string, modelID: string, options?: ModelInput) {
|
||||
return new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
|
||||
apiID: ModelV2.ID.make(modelID),
|
||||
endpoint: {
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
},
|
||||
...options,
|
||||
options: {
|
||||
api:
|
||||
options?.api && "type" in options.api
|
||||
? { id: ModelV2.ID.make(modelID), ...options.api }
|
||||
: {
|
||||
id: ModelV2.ID.make(modelID),
|
||||
...options?.api,
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
},
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
...options?.options,
|
||||
...options?.request,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,21 +25,21 @@ describe("KiloPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const kilo = provider("kilo", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
||||
request: { headers: { Existing: "value" }, body: {} },
|
||||
})
|
||||
catalog.provider.update(kilo.id, (draft) => {
|
||||
draft.endpoint = kilo.endpoint
|
||||
draft.options = kilo.options
|
||||
draft.api = kilo.api
|
||||
draft.request = kilo.request
|
||||
})
|
||||
catalog.provider.update(provider("openrouter").id, () => {})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).options.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -51,21 +51,21 @@ describe("KiloPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("kilo", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
draft.api = item.api
|
||||
})
|
||||
})
|
||||
|
||||
const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo"))
|
||||
expect(result.options.headers).toEqual({
|
||||
expect(result.request.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect(result.options.headers).not.toHaveProperty("http-referer")
|
||||
expect(result.options.headers).not.toHaveProperty("x-title")
|
||||
expect(result.options.headers).not.toHaveProperty("X-Source")
|
||||
expect(result.request.headers).not.toHaveProperty("http-referer")
|
||||
expect(result.request.headers).not.toHaveProperty("x-title")
|
||||
expect(result.request.headers).not.toHaveProperty("X-Source")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -77,24 +77,24 @@ describe("KiloPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const kilo = provider("kilo", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
|
||||
})
|
||||
catalog.provider.update(kilo.id, (draft) => {
|
||||
draft.endpoint = kilo.endpoint
|
||||
draft.api = kilo.api
|
||||
})
|
||||
const custom = provider("custom-kilo", {
|
||||
endpoint: { type: "aisdk", package: "kilo" },
|
||||
api: { type: "aisdk", package: "kilo" },
|
||||
})
|
||||
catalog.provider.update(custom.id, (draft) => {
|
||||
draft.endpoint = custom.endpoint
|
||||
draft.api = custom.api
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).options.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).request.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).options.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@ describe("LLMGatewayPlugin", () => {
|
|||
yield* transform((catalog) => {
|
||||
const llmgateway = provider("llmgateway", {
|
||||
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||
request: { headers: { Existing: "value" }, body: {} },
|
||||
})
|
||||
catalog.provider.update(llmgateway.id, (draft) => {
|
||||
draft.enabled = llmgateway.enabled
|
||||
draft.endpoint = llmgateway.endpoint
|
||||
draft.options = llmgateway.options
|
||||
draft.api = llmgateway.api
|
||||
draft.request = llmgateway.request
|
||||
})
|
||||
const openrouter = provider("openrouter", {
|
||||
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
|
||||
|
|
@ -41,13 +41,13 @@ describe("LLMGatewayPlugin", () => {
|
|||
draft.enabled = openrouter.enabled
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).options.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-Source": "opencode",
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -59,15 +59,15 @@ describe("LLMGatewayPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("llmgateway", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
draft.api = item.api
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).options.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ describe("MistralPlugin", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("leaves Mistral language selection on the default sdk.languageModel(apiID) path", () =>
|
||||
it.effect("leaves Mistral language selection on the default sdk.languageModel(api.id) path", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
|
|
@ -95,10 +95,10 @@ describe("MistralPlugin", () => {
|
|||
yield* plugin.add(MistralPlugin)
|
||||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{ model: model("mistral", "alias", { apiID: ModelV2.ID.make("mistral-large") }), sdk, options: {} },
|
||||
{ model: model("mistral", "alias", { api: { id: ModelV2.ID.make("mistral-large") } }), sdk, options: {} },
|
||||
{},
|
||||
)
|
||||
const language = result.language ?? sdk.languageModel(result.model.apiID)
|
||||
const language = result.language ?? sdk.languageModel(result.model.api.id)
|
||||
expect(calls).toEqual(["languageModel:mistral-large"])
|
||||
expect(language).toBeDefined()
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -25,22 +25,22 @@ describe("NvidiaPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const nvidia = provider("nvidia", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
request: { headers: { Existing: "value" }, body: {} },
|
||||
})
|
||||
catalog.provider.update(nvidia.id, (draft) => {
|
||||
draft.endpoint = nvidia.endpoint
|
||||
draft.options = nvidia.options
|
||||
draft.api = nvidia.api
|
||||
draft.request = nvidia.request
|
||||
})
|
||||
catalog.provider.update(provider("openrouter").id, () => {})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -52,16 +52,16 @@ describe("NvidiaPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("nvidia", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
request: { headers: {}, body: {} },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
draft.options = item.options
|
||||
draft.api = item.api
|
||||
draft.request = item.request
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||
|
|
@ -77,20 +77,19 @@ describe("NvidiaPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("nvidia", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
options: {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
|
||||
request: {
|
||||
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
|
||||
body: {},
|
||||
aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} },
|
||||
body: { baseURL: "https://integrate.api.nvidia.com/v1" },
|
||||
},
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
draft.options = item.options
|
||||
draft.api = item.api
|
||||
draft.request = item.request
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ describe("OpenAIPlugin", () => {
|
|||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("openai", "alias", { apiID: ModelV2.ID.make("gpt-5") }),
|
||||
model: model("openai", "alias", {
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
|
|
@ -79,9 +81,9 @@ describe("OpenAIPlugin", () => {
|
|||
yield* plugin.add(OpenAIPlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("openai", { endpoint: { type: "aisdk", package: "@ai-sdk/openai" } })
|
||||
const item = provider("openai", { api: { type: "aisdk", package: "@ai-sdk/openai" } })
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
draft.api = item.api
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ describe("OpencodePlugin", () => {
|
|||
draft.cost = [...paid.cost]
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
|
||||
}),
|
||||
),
|
||||
|
|
@ -54,7 +54,7 @@ describe("OpencodePlugin", () => {
|
|||
draft.cost = [...free.cost]
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
|
@ -75,7 +75,7 @@ describe("OpencodePlugin", () => {
|
|||
draft.cost = [...outputOnly.cost]
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
|
@ -96,7 +96,7 @@ describe("OpencodePlugin", () => {
|
|||
draft.cost = [...paid.cost]
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
|
@ -119,7 +119,7 @@ describe("OpencodePlugin", () => {
|
|||
draft.cost = [...paid.cost]
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
|
@ -134,24 +134,20 @@ describe("OpencodePlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode", {
|
||||
options: {
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: { apiKey: "configured" },
|
||||
request: {},
|
||||
},
|
||||
body: { apiKey: "configured" },
|
||||
},
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.options = item.options
|
||||
draft.request = item.request
|
||||
})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("configured")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured")
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
|
@ -174,7 +170,7 @@ describe("OpencodePlugin", () => {
|
|||
draft.cost = [...paid.cost]
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
|
@ -195,7 +191,7 @@ describe("OpencodePlugin", () => {
|
|||
draft.cost = [...paid.cost]
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.aisdk.provider.apiKey).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined()
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -26,22 +26,22 @@ describe("OpenRouterPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const openrouter = provider("openrouter", {
|
||||
endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
|
||||
request: { headers: { Existing: "value" }, body: {} },
|
||||
})
|
||||
catalog.provider.update(openrouter.id, (item) => {
|
||||
item.endpoint = openrouter.endpoint
|
||||
item.options = openrouter.options
|
||||
item.api = openrouter.api
|
||||
item.request = openrouter.request
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).options.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).request.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -78,10 +78,10 @@ describe("OpenRouterPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const openrouter = provider("openrouter", {
|
||||
endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
|
||||
api: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
|
||||
})
|
||||
catalog.provider.update(openrouter.id, (item) => {
|
||||
item.endpoint = openrouter.endpoint
|
||||
item.api = openrouter.api
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
||||
for (const item of [
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ describe("PerplexityPlugin", () => {
|
|||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: model("perplexity", "alias", { apiID: ModelV2.ID.make("sonar") }),
|
||||
model: model("perplexity", "alias", { api: { id: ModelV2.ID.make("sonar") } }),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ describe("TogetherAIPlugin", () => {
|
|||
|
||||
expect(result.language).toBeUndefined()
|
||||
expect(calls).toEqual([])
|
||||
expect(result.language ?? fakeSelectorSdk(calls).languageModel(result.model.apiID)).toBeDefined()
|
||||
expect(result.language ?? fakeSelectorSdk(calls).languageModel(result.model.api.id)).toBeDefined()
|
||||
expect(calls).toEqual(["languageModel:meta-llama/Llama-3.3-70B-Instruct-Turbo"])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,15 +15,15 @@ describe("VercelPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("vercel", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/vercel" },
|
||||
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
api: { type: "aisdk", package: "@ai-sdk/vercel" },
|
||||
request: { headers: { Existing: "1" }, body: {} },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
draft.options = item.options
|
||||
draft.api = item.api
|
||||
draft.request = item.request
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).toEqual({
|
||||
Existing: "1",
|
||||
"http-referer": "https://opencode.ai/",
|
||||
"x-title": "opencode",
|
||||
|
|
@ -38,15 +38,15 @@ describe("VercelPlugin", () => {
|
|||
yield* plugin.add(VercelPlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" } })
|
||||
const item = provider("vercel", { api: { type: "aisdk", package: "@ai-sdk/vercel" } })
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
draft.api = item.api
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).not.toHaveProperty(
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty(
|
||||
"HTTP-Referer",
|
||||
)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).not.toHaveProperty("X-Title")
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).request.headers).not.toHaveProperty("X-Title")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ describe("VercelPlugin", () => {
|
|||
yield* plugin.add(VercelPlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).options.headers).toEqual({})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultL
|
|||
|
||||
const model = new ModelV2.Info({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
|
||||
apiID: ModelV2.ID.make("grok-4"),
|
||||
endpoint: {
|
||||
api: {
|
||||
id: ModelV2.ID.make("grok-4"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/xai",
|
||||
},
|
||||
|
|
@ -72,7 +72,7 @@ describe("XAIPlugin", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("uses responses with the model apiID for xAI language models", () =>
|
||||
it.effect("uses responses with the model api.id for xAI language models", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const calls: string[] = []
|
||||
|
|
@ -81,7 +81,7 @@ describe("XAIPlugin", () => {
|
|||
const result = yield* plugin.trigger(
|
||||
"aisdk.language",
|
||||
{
|
||||
model: new ModelV2.Info({ ...model, id: ModelV2.ID.make("alias"), apiID: ModelV2.ID.make("grok-4") }),
|
||||
model: new ModelV2.Info({ ...model, id: ModelV2.ID.make("alias") }),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,15 +25,15 @@ describe("ZenmuxPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("zenmux", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
draft.api = item.api
|
||||
})
|
||||
})
|
||||
const result = yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))
|
||||
expect(result.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
|
||||
expect(Object.keys(result.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
|
||||
expect(result.request.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
|
||||
expect(Object.keys(result.request.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -45,16 +45,16 @@ describe("ZenmuxPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("zenmux", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
request: { headers: { Existing: "value" }, body: {} },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
draft.options = item.options
|
||||
draft.api = item.api
|
||||
draft.request = item.request
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).options.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
|
|
@ -70,20 +70,19 @@ describe("ZenmuxPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("zenmux", {
|
||||
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
options: {
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
|
||||
request: {
|
||||
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
|
||||
body: {},
|
||||
aisdk: { provider: {}, request: {} },
|
||||
},
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.endpoint = item.endpoint
|
||||
draft.options = item.options
|
||||
draft.api = item.api
|
||||
draft.request = item.request
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).options.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).request.headers).toEqual({
|
||||
"HTTP-Referer": "https://example.com/",
|
||||
"X-Title": "custom-title",
|
||||
})
|
||||
|
|
@ -98,18 +97,17 @@ describe("ZenmuxPlugin", () => {
|
|||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("openrouter", {
|
||||
options: {
|
||||
request: {
|
||||
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
|
||||
body: {},
|
||||
aisdk: { provider: {}, request: {} },
|
||||
},
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.options = item.options
|
||||
draft.request = item.request
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({
|
||||
"HTTP-Referer": "https://example.com/",
|
||||
"X-Title": "custom-title",
|
||||
})
|
||||
|
|
|
|||
32
packages/core/test/plugin/skill.test.ts
Normal file
32
packages/core/test/plugin/skill.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
SkillV2.layer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(SkillDiscovery.defaultLayer),
|
||||
Layer.provideMerge(AgentV2.locationLayer),
|
||||
),
|
||||
)
|
||||
|
||||
describe("SkillPlugin.Plugin", () => {
|
||||
it.effect("registers the built-in customize-opencode skill", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* SkillV2.Service
|
||||
yield* SkillPlugin.Plugin.effect.pipe(Effect.provideService(SkillV2.Service, skill))
|
||||
|
||||
expect(yield* skill.list()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "customize-opencode",
|
||||
description: expect.stringContaining("opencode's own configuration"),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import { realpathSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { Effect, Exit, Stream } from "effect"
|
||||
import path from "node:path"
|
||||
import { Effect, Exit, Fiber, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
|
@ -11,6 +13,18 @@ const it = testEffect(AppProcess.defaultLayer)
|
|||
const NODE = process.execPath
|
||||
const cmd = (...args: string[]) => ChildProcess.make(NODE, args)
|
||||
|
||||
const waitForFile = (file: string) =>
|
||||
Effect.promise(async () => {
|
||||
while (true) {
|
||||
try {
|
||||
return await fs.readFile(file, "utf8")
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("AppProcess", () => {
|
||||
describe("run", () => {
|
||||
it.effect(
|
||||
|
|
@ -118,6 +132,50 @@ describe("AppProcess", () => {
|
|||
expect(result.command).toBe(`${NODE} -e process.stdout.write('hi')`)
|
||||
}),
|
||||
)
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
it.live(
|
||||
"timeout cleans up the scoped child process",
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => fs.mkdtemp(path.join(tmpdir(), "opencode-process-timeout-"))),
|
||||
(directory) => {
|
||||
const ready = path.join(directory, "ready")
|
||||
const settled = path.join(directory, "settled")
|
||||
const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)`
|
||||
return Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "1 second" }))
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(yield* waitForFile(ready)).toMatch(/^\d+$/)
|
||||
expect(yield* waitForFile(settled)).toBe("settled")
|
||||
})
|
||||
},
|
||||
(directory) => Effect.promise(() => fs.rm(directory, { recursive: true, force: true })),
|
||||
),
|
||||
5_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"fiber interruption cleans up the scoped child process after readiness",
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => fs.mkdtemp(path.join(tmpdir(), "opencode-process-interrupt-"))),
|
||||
(directory) => {
|
||||
const ready = path.join(directory, "ready")
|
||||
const settled = path.join(directory, "settled")
|
||||
const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)`
|
||||
return Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const fiber = yield* svc.run(cmd("-e", script)).pipe(Effect.forkChild)
|
||||
expect(yield* waitForFile(ready)).toMatch(/^\d+$/)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
expect(yield* waitForFile(settled)).toBe("settled")
|
||||
})
|
||||
},
|
||||
(directory) => Effect.promise(() => fs.rm(directory, { recursive: true, force: true })),
|
||||
),
|
||||
5_000,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe("inherited platform methods", () => {
|
||||
|
|
|
|||
191
packages/core/test/project-copy.test.ts
Normal file
191
packages/core/test/project-copy.test.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const databaseLayer = Database.layerFromPath(":memory:")
|
||||
const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
|
||||
const copyLayer = ProjectCopy.layer.pipe(
|
||||
Layer.provide(databaseLayer),
|
||||
Layer.provide(eventLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer))
|
||||
|
||||
function abs(input: string) {
|
||||
return AbsolutePath.make(input)
|
||||
}
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(directory).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
|
||||
await $`git config user.name Test`.cwd(directory).quiet()
|
||||
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
|
||||
}
|
||||
|
||||
function setup() {
|
||||
return Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const sourceDirectory = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const projectID = Project.ID.make("copy-project")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: projectID, worktree: sourceDirectory, sandboxes: [], time_created: 1, time_updated: 1 })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: projectID, directory: sourceDirectory, type: "main" })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return { root, sourceDirectory, projectID, db }
|
||||
})
|
||||
}
|
||||
|
||||
function stored(projectID: Project.ID) {
|
||||
return Database.Service.use(({ db }) =>
|
||||
db
|
||||
.select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, projectID))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows.toSorted((a, b) => a.directory.localeCompare(b.directory))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ProjectCopy", () => {
|
||||
it.live("detects linked git worktrees but not root checkouts", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const target = abs(`${input.root.path}-copy-detected`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
|
||||
|
||||
expect(yield* copy.detect({ directory: input.sourceDirectory })).toBeUndefined()
|
||||
expect(yield* copy.detect({ directory: target })).toBe("git_worktree")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates and removes a git worktree directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const events = yield* EventV2.Service
|
||||
const target = abs(`${input.root.path}-copy-created`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
const fiber = yield* events
|
||||
.subscribe(ProjectCopy.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const created = yield* copy.create({
|
||||
projectID: input.projectID,
|
||||
strategy: "git_worktree",
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: target,
|
||||
})
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, type: "main" as const },
|
||||
{ directory: created.directory, type: "git_worktree" as const },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
|
||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
|
||||
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not publish an event when refresh finds no directory changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const events = yield* EventV2.Service
|
||||
const event = yield* events.subscribe(ProjectCopy.Event.Updated).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped,
|
||||
Effect.flatMap((fiber) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(event._tag).toBe("None")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh discovers and prunes an externally managed git worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const events = yield* EventV2.Service
|
||||
const target = abs(`${input.root.path}-copy-external`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
|
||||
const fiber = yield* events
|
||||
.subscribe(ProjectCopy.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
|
||||
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, type: "main" as const },
|
||||
{ directory: discovered, type: "git_worktree" as const },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
|
||||
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh with no roots is a no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
const copy = yield* ProjectCopy.Service
|
||||
|
||||
yield* copy.refresh({ projectID: Project.ID.make("missing-project") })
|
||||
}),
|
||||
)
|
||||
})
|
||||
299
packages/core/test/project-reference.test.ts
Normal file
299
packages/core/test/project-reference.test.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigReference } from "@opencode-ai/core/config/reference"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
describe("ProjectReference", () => {
|
||||
it.live("uses the broad experimental flag unless references are explicitly configured", () =>
|
||||
withEnv(
|
||||
{ OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: undefined },
|
||||
Effect.sync(() => {
|
||||
expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(true)
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap(() =>
|
||||
withEnv(
|
||||
{ OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: "false" },
|
||||
Effect.sync(() => {
|
||||
expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(false)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("normalizes aliases and resolves relative local paths from the project root", () =>
|
||||
withTmp((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const nested = path.join(project, "packages", "app")
|
||||
yield* Effect.promise(() => fs.mkdir(nested, { recursive: true }))
|
||||
|
||||
const references = ProjectReference.resolveAll({
|
||||
references: ConfigReference.normalize({
|
||||
docs: { path: "./docs" },
|
||||
home: "~/notes",
|
||||
sdk: { repository: "owner/repo", branch: "main" },
|
||||
shorthand: "owner/other",
|
||||
invalid: "not-a-repo",
|
||||
"bad/name": "owner/repo",
|
||||
}),
|
||||
directory: project,
|
||||
home: path.join(tmp.path, "home"),
|
||||
repos: path.join(tmp.path, "repos"),
|
||||
})
|
||||
|
||||
expect(references).toMatchObject([
|
||||
{ name: "docs", kind: "local", path: path.join(project, "docs") },
|
||||
{ name: "home", kind: "local", path: path.join(tmp.path, "home", "notes") },
|
||||
{ name: "sdk", kind: "git", branch: "main" },
|
||||
{ name: "shorthand", kind: "git" },
|
||||
{ name: "invalid", kind: "invalid", repository: "not-a-repo" },
|
||||
{ name: "bad/name", kind: "invalid" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("marks same-cache references with different branches invalid", () =>
|
||||
Effect.sync(() => {
|
||||
const references = ProjectReference.resolveAll({
|
||||
references: ConfigReference.normalize({
|
||||
main: { repository: "owner/repo", branch: "main" },
|
||||
dev: { repository: "github.com/owner/repo", branch: "dev" },
|
||||
alsoMain: { repository: "https://github.com/owner/repo", branch: "main" },
|
||||
}),
|
||||
directory: "/project",
|
||||
home: "/home",
|
||||
repos: "/repos",
|
||||
})
|
||||
|
||||
expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"])
|
||||
expect(references[1]?.kind === "invalid" ? references[1].message : "").toContain("conflicts with @main")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("merges config aliases and exposes mention and managed-path operations", () =>
|
||||
withoutReferences(
|
||||
withTmp((tmp) => {
|
||||
const calls: RepositoryCache.EnsureInput[] = []
|
||||
const project = path.join(tmp.path, "project")
|
||||
const nested = path.join(project, "packages", "app")
|
||||
const docs = path.join(project, "docs")
|
||||
const repos = path.join(tmp.path, "repos")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(nested, { recursive: true })
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(path.join(docs, "README.md"), "docs")
|
||||
})
|
||||
|
||||
yield* withReferences(
|
||||
Effect.gen(function* () {
|
||||
const references = yield* ProjectReference.Service
|
||||
const git = path.join(repos, "github.com", "owner", "repo")
|
||||
|
||||
expect(yield* references.list()).toMatchObject([
|
||||
{ name: "docs", kind: "local", path: docs },
|
||||
{ name: "sdk", kind: "git", path: git },
|
||||
])
|
||||
expect(yield* references.resolveMention("docs/README.md")).toMatchObject({
|
||||
name: "docs",
|
||||
kind: "reference",
|
||||
target: "README.md",
|
||||
path: path.join(docs, "README.md"),
|
||||
})
|
||||
expect(yield* references.resolveMention("docs/missing.md")).toMatchObject({
|
||||
name: "docs",
|
||||
kind: "missing",
|
||||
})
|
||||
expect(yield* references.resolveMention("docs/../outside.md")).toMatchObject({
|
||||
name: "docs",
|
||||
kind: "invalid",
|
||||
})
|
||||
expect(yield* references.resolveMention("unknown")).toBeUndefined()
|
||||
expect(yield* references.resolveMention("sdk")).toMatchObject({
|
||||
name: "sdk",
|
||||
kind: "reference",
|
||||
path: git,
|
||||
})
|
||||
expect(yield* references.containsManagedPath(path.join(git, "README.md"))).toBe(true)
|
||||
expect(yield* references.containsManagedPath(path.join(docs, "README.md"))).toBe(false)
|
||||
yield* references.ensurePath()
|
||||
expect(calls).toHaveLength(1)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer({
|
||||
directory: nested,
|
||||
project,
|
||||
repos,
|
||||
documents: [
|
||||
document({ docs: { path: "./old-docs" }, sdk: "owner/old" }),
|
||||
document({ docs: { path: "./docs" }, sdk: { repository: "owner/repo", branch: "main" } }),
|
||||
],
|
||||
ensure: (input) => Effect.sync(() => result(repos, calls, input)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("is inert while the runtime flag is disabled", () =>
|
||||
withoutReferences(
|
||||
withTmp((tmp) => {
|
||||
const calls: RepositoryCache.EnsureInput[] = []
|
||||
return Effect.gen(function* () {
|
||||
const references = yield* ProjectReference.Service
|
||||
expect(yield* references.list()).toEqual([])
|
||||
expect(yield* references.get("sdk")).toBeUndefined()
|
||||
expect(yield* references.resolveMention("sdk")).toBeUndefined()
|
||||
expect(
|
||||
yield* references.containsManagedPath(path.join(tmp.path, "repos", "github.com", "owner", "repo")),
|
||||
).toBe(false)
|
||||
yield* references.ensurePath()
|
||||
expect(calls).toEqual([])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer({
|
||||
directory: tmp.path,
|
||||
project: tmp.path,
|
||||
repos: path.join(tmp.path, "repos"),
|
||||
documents: [document({ sdk: "owner/repo" })],
|
||||
ensure: (input) => Effect.sync(() => result(path.join(tmp.path, "repos"), calls, input)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("starts Git materialization in the background without blocking the location layer", () =>
|
||||
withTmp((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
yield* withReferences(
|
||||
Effect.gen(function* () {
|
||||
expect(yield* (yield* ProjectReference.Service).list()).toHaveLength(1)
|
||||
yield* Deferred.await(started).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "1 second",
|
||||
orElse: () => Effect.die(new Error("refresh did not start")),
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer({
|
||||
directory: tmp.path,
|
||||
project: tmp.path,
|
||||
repos: path.join(tmp.path, "repos"),
|
||||
documents: [document({ sdk: "owner/repo" })],
|
||||
ensure: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function document(references: ConfigReference.Info) {
|
||||
return new Config.Document({ type: "document", info: Schema.decodeUnknownSync(Config.Info)({ references }) })
|
||||
}
|
||||
|
||||
function result(
|
||||
repos: string,
|
||||
calls: RepositoryCache.EnsureInput[],
|
||||
input: RepositoryCache.EnsureInput,
|
||||
): RepositoryCache.Result {
|
||||
calls.push(input)
|
||||
return {
|
||||
repository: input.reference.label,
|
||||
host: input.reference.host,
|
||||
remote: input.reference.remote,
|
||||
localPath: Repository.cachePath(repos, input.reference),
|
||||
status: "cached",
|
||||
branch: input.branch,
|
||||
}
|
||||
}
|
||||
|
||||
function testLayer(input: {
|
||||
directory: string
|
||||
project: string
|
||||
repos: string
|
||||
documents: Config.Document[]
|
||||
ensure: RepositoryCache.Interface["ensure"]
|
||||
}) {
|
||||
return ProjectReference.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
Global.layerWith({ home: path.join(input.directory, "home"), repos: input.repos }),
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(input.directory) },
|
||||
{ projectDirectory: AbsolutePath.make(input.project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(input.documents) })),
|
||||
Layer.succeed(RepositoryCache.Service, RepositoryCache.Service.of({ ensure: input.ensure })),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(body: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
body,
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
}
|
||||
|
||||
function withReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
|
||||
return withEnv({ OPENCODE_EXPERIMENTAL_REFERENCES: "true" }, body)
|
||||
}
|
||||
|
||||
function withoutReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
|
||||
return withEnv({ OPENCODE_EXPERIMENTAL: undefined, OPENCODE_EXPERIMENTAL_REFERENCES: undefined }, body)
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(env: Record<string, string | undefined>, body: Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]))
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
return previous
|
||||
}),
|
||||
() => body,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -2,14 +2,28 @@ import { describe, expect } from "bun:test"
|
|||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(ProjectV2.defaultLayer)
|
||||
const databaseLayer = Database.layerFromPath(":memory:")
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
ProjectV2.layer.pipe(
|
||||
Layer.provide(databaseLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
),
|
||||
databaseLayer,
|
||||
),
|
||||
)
|
||||
|
||||
function remoteID(remote: string) {
|
||||
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
|
||||
|
|
@ -37,6 +51,52 @@ async function rootCommit(dir: string) {
|
|||
return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
|
||||
}
|
||||
|
||||
describe("Project directories schemas", () => {
|
||||
it.effect("decodes project directory input and inline directory results", () =>
|
||||
Effect.sync(() => {
|
||||
expect(Schema.decodeUnknownSync(ProjectV2.DirectoriesInput)({ projectID: ProjectV2.ID.make("project") })).toEqual(
|
||||
{
|
||||
projectID: ProjectV2.ID.make("project"),
|
||||
},
|
||||
)
|
||||
expect(Schema.decodeUnknownSync(ProjectV2.Directories)([AbsolutePath.make("/tmp/project")])).toEqual([
|
||||
AbsolutePath.make("/tmp/project"),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lists stored project directories only for the requested project", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* ProjectV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const projectID = ProjectV2.ID.make("directories-project")
|
||||
const otherID = ProjectV2.ID.make("directories-other")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values([
|
||||
{ id: projectID, worktree: AbsolutePath.make("/repo"), sandboxes: [], time_created: 1, time_updated: 1 },
|
||||
{ id: otherID, worktree: AbsolutePath.make("/other"), sandboxes: [], time_created: 1, time_updated: 1 },
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values([
|
||||
{ project_id: projectID, directory: AbsolutePath.make("/repo/z"), type: "root" },
|
||||
{ project_id: projectID, directory: AbsolutePath.make("/repo/a"), type: "main" },
|
||||
{ project_id: otherID, directory: AbsolutePath.make("/other"), type: "main" },
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(yield* project.directories({ projectID })).toEqual([
|
||||
AbsolutePath.make("/repo/a"),
|
||||
AbsolutePath.make("/repo/z"),
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("ProjectV2.resolve", () => {
|
||||
it.live("returns global for non-git directory", () =>
|
||||
Effect.gen(function* () {
|
||||
|
|
|
|||
27
packages/core/test/pty/info-schema.test.ts
Normal file
27
packages/core/test/pty/info-schema.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
|
||||
const sample = (pid: number) => ({
|
||||
id: "pty_01J5Y5H0AH4Q4NXJ6P4C3P5V2K",
|
||||
title: "demo",
|
||||
command: "cmd.exe",
|
||||
args: [],
|
||||
cwd: "C:\\",
|
||||
status: "running",
|
||||
pid,
|
||||
})
|
||||
|
||||
describe("Pty.Info", () => {
|
||||
test("accepts pid 0 (Windows ConPTY assigns the pid asynchronously)", () => {
|
||||
expect(Schema.decodeUnknownSync(Pty.Info)(sample(0)).pid).toBe(0)
|
||||
})
|
||||
|
||||
test("accepts a positive pid", () => {
|
||||
expect(Schema.decodeUnknownSync(Pty.Info)(sample(48012)).pid).toBe(48012)
|
||||
})
|
||||
|
||||
test("rejects a negative pid", () => {
|
||||
expect(() => Schema.decodeUnknownSync(Pty.Info)(sample(-1))).toThrow()
|
||||
})
|
||||
})
|
||||
19
packages/core/test/pty/input.test.ts
Normal file
19
packages/core/test/pty/input.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { handlePtyInput } from "@opencode-ai/core/pty/input"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("pty websocket input", () => {
|
||||
it.effect("does not forward invalid binary frames to the PTY handler", () =>
|
||||
Effect.gen(function* () {
|
||||
const messages: Array<string | ArrayBuffer> = []
|
||||
const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) }
|
||||
|
||||
yield* handlePtyInput(handler, "ready")
|
||||
yield* handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd]))
|
||||
yield* handlePtyInput(handler, new TextEncoder().encode("hello"))
|
||||
|
||||
expect(messages).toEqual(["ready", "hello"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
110
packages/core/test/pty/pty-output-isolation.test.ts
Normal file
110
packages/core/test/pty/pty-output-isolation.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Queue } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
type Socket = Parameters<Pty.Interface["connect"]>[1]
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
|
||||
)
|
||||
const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)))
|
||||
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
|
||||
|
||||
const createPty = Effect.fn("PtyOutputIsolationTest.createPty")(function* (command: string) {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* Effect.acquireRelease(
|
||||
pty.create({ command, args: [], cwd: "/tmp", env: { TERM: "xterm-256color", OPENCODE_TERMINAL: "1" } }),
|
||||
(info) => pty.remove(info.id).pipe(Effect.ignore),
|
||||
)
|
||||
})
|
||||
|
||||
const decodeOutput = (data: string | Uint8Array | ArrayBuffer) =>
|
||||
typeof data === "string"
|
||||
? data
|
||||
: Buffer.from(data instanceof Uint8Array ? data : new Uint8Array(data)).toString("utf8")
|
||||
|
||||
const makeSocket = Effect.fn("PtyOutputIsolationTest.makeSocket")(function* (data: unknown) {
|
||||
const output = yield* Queue.unbounded<string>()
|
||||
const socket: Socket = {
|
||||
readyState: 1,
|
||||
data,
|
||||
send: (data) => Queue.offerUnsafe(output, decodeOutput(data)),
|
||||
close: () => {},
|
||||
}
|
||||
return { socket, output }
|
||||
})
|
||||
|
||||
const waitForOutput = (output: Queue.Queue<string>, text: string, duration: Duration.Input = "5 seconds") =>
|
||||
Effect.gen(function* () {
|
||||
let received = ""
|
||||
while (!received.includes(text)) received += yield* Queue.take(output)
|
||||
return received
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration,
|
||||
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
|
||||
}),
|
||||
)
|
||||
|
||||
describe("pty output isolation", () => {
|
||||
ptyTest("does not leak output when websocket objects are reused", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const a = yield* createPty("cat")
|
||||
const b = yield* createPty("cat")
|
||||
const shared = yield* makeSocket({ events: { connection: "a" } })
|
||||
const outB = yield* Queue.unbounded<string>()
|
||||
|
||||
yield* pty.connect(a.id, shared.socket)
|
||||
shared.socket.data = { events: { connection: "b" } }
|
||||
shared.socket.send = (data) => Queue.offerUnsafe(outB, decodeOutput(data))
|
||||
yield* pty.connect(b.id, shared.socket)
|
||||
yield* pty.write(a.id, "AAA\n")
|
||||
|
||||
const verify = yield* makeSocket({ events: { connection: "verify-a" } })
|
||||
yield* pty.connect(a.id, verify.socket)
|
||||
expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA")
|
||||
expect(yield* waitForOutput(outB, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" })
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("does not leak output when Bun recycles websocket objects before re-connect", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* createPty("cat")
|
||||
const first = yield* makeSocket({ events: { connection: "a" } })
|
||||
const recycled = yield* Queue.unbounded<string>()
|
||||
|
||||
yield* pty.connect(info.id, first.socket)
|
||||
first.socket.data = { events: { connection: "b" } }
|
||||
first.socket.send = (data) => Queue.offerUnsafe(recycled, decodeOutput(data))
|
||||
yield* pty.write(info.id, "AAA\n")
|
||||
|
||||
const verify = yield* makeSocket({ events: { connection: "verify" } })
|
||||
yield* pty.connect(info.id, verify.socket)
|
||||
expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA")
|
||||
expect(yield* waitForOutput(recycled, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" })
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("treats in-place socket data mutation as the same connection", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* createPty("cat")
|
||||
const data = { connId: 1 }
|
||||
const socket = yield* makeSocket(data)
|
||||
|
||||
yield* pty.connect(info.id, socket.socket)
|
||||
data.connId = 2
|
||||
yield* pty.write(info.id, "AAA\n")
|
||||
|
||||
expect(yield* waitForOutput(socket.output, "AAA")).toContain("AAA")
|
||||
}),
|
||||
)
|
||||
})
|
||||
91
packages/core/test/pty/pty-session.test.ts
Normal file
91
packages/core/test/pty/pty-session.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer, Queue } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import type { PtyID } from "@opencode-ai/core/pty/schema"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
|
||||
)
|
||||
const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)))
|
||||
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
|
||||
|
||||
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
|
||||
const source = yield* EventV2.Service
|
||||
const events = yield* Queue.unbounded<PtyEvent>()
|
||||
const unsubscribe = yield* source.listen((event) => {
|
||||
if (event.type === Pty.Event.Created.type)
|
||||
Queue.offerUnsafe(events, { type: "created", id: (event.data as typeof Pty.Event.Created.data.Type).info.id })
|
||||
if (event.type === Pty.Event.Exited.type)
|
||||
Queue.offerUnsafe(events, { type: "exited", id: (event.data as typeof Pty.Event.Exited.data.Type).id })
|
||||
if (event.type === Pty.Event.Deleted.type)
|
||||
Queue.offerUnsafe(events, { type: "deleted", id: (event.data as typeof Pty.Event.Deleted.data.Type).id })
|
||||
return Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
return events
|
||||
})
|
||||
|
||||
const createPty = Effect.fn("PtySessionTest.createPty")(function* (command: string, args: string[] = []) {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* Effect.acquireRelease(
|
||||
pty.create({ command, args, cwd: "/tmp", env: { TERM: "xterm-256color", OPENCODE_TERMINAL: "1" } }),
|
||||
(info) => pty.remove(info.id).pipe(Effect.ignore),
|
||||
)
|
||||
})
|
||||
|
||||
const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number) =>
|
||||
Effect.gen(function* () {
|
||||
const picked: Array<PtyEvent["type"]> = []
|
||||
while (picked.length < count) {
|
||||
const evt = yield* Queue.take(events)
|
||||
if (evt.id === id) picked.push(evt.type)
|
||||
}
|
||||
return picked
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () => Effect.fail(new Error("timeout waiting for pty events")),
|
||||
}),
|
||||
)
|
||||
|
||||
describe("pty", () => {
|
||||
it.live("returns typed not found errors for missing sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const id = "pty_missing" as PtyID
|
||||
let closed = false
|
||||
const socket = { readyState: 1, send: () => {}, close: () => void (closed = true) }
|
||||
|
||||
for (const result of [
|
||||
yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
|
||||
yield* pty.update(id, { title: "missing" }).pipe(Effect.asVoid, Effect.exit),
|
||||
yield* pty.remove(id).pipe(Effect.exit),
|
||||
yield* pty.resize(id, 80, 24).pipe(Effect.exit),
|
||||
yield* pty.write(id, "input").pipe(Effect.exit),
|
||||
yield* pty.connect(id, socket).pipe(Effect.asVoid, Effect.exit),
|
||||
]) {
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
if (Exit.isFailure(result))
|
||||
expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
|
||||
}
|
||||
expect(closed).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("publishes created, exited, deleted in order for a short-lived process", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* subscribePtyEvents()
|
||||
const info = yield* createPty("/usr/bin/env", ["sh", "-c", "sleep 0.1"])
|
||||
|
||||
expect(yield* waitForEvents(events, info.id, 3)).toEqual(["created", "exited", "deleted"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
59
packages/core/test/pty/ticket.test.ts
Normal file
59
packages/core/test/pty/ticket.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { PtyID } from "@opencode-ai/core/pty/schema"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(PtyTicket.layer)
|
||||
const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))
|
||||
|
||||
describe("PTY websocket tickets", () => {
|
||||
it.live("consumes tickets once", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const scope = { ptyID: PtyID.ascending(), directory: "/tmp/a" }
|
||||
const issued = yield* tickets.issue(scope)
|
||||
|
||||
expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(true)
|
||||
expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects tickets scoped to a different request", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const ptyID = PtyID.ascending()
|
||||
const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" })
|
||||
|
||||
expect(yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket })).toBe(false)
|
||||
expect(yield* tickets.consume({ ptyID, directory: "/tmp/a", ticket: issued.ticket })).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
itExpiring.live("rejects tickets after the TTL elapses", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const ptyID = PtyID.ascending()
|
||||
const issued = yield* tickets.issue({ ptyID })
|
||||
|
||||
yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25)))
|
||||
|
||||
expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects tickets scoped to a different workspace", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const ptyID = PtyID.ascending()
|
||||
const workspaceID = WorkspaceV2.ID.ascending()
|
||||
const issued = yield* tickets.issue({ ptyID, workspaceID })
|
||||
|
||||
expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceV2.ID.ascending(), ticket: issued.ticket })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
115
packages/core/test/question.test.ts
Normal file
115
packages/core/test/question.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const questions = QuestionV2.layer.pipe(Layer.provide(events))
|
||||
const it = testEffect(Layer.mergeAll(database, events, questions))
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_question_test")
|
||||
const question: QuestionV2.Info = {
|
||||
question: "Which option?",
|
||||
header: "Option",
|
||||
options: [{ label: "One", description: "First option" }],
|
||||
}
|
||||
|
||||
const waitForAsk = Effect.fn("QuestionV2Test.waitForAsk")(function* (
|
||||
service: QuestionV2.Interface,
|
||||
input: QuestionV2.AskInput,
|
||||
) {
|
||||
const events = yield* EventV2.Service
|
||||
const asked = yield* Deferred.make<QuestionV2.Request>()
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
event.type === QuestionV2.Event.Asked.type
|
||||
? Deferred.succeed(asked, event.data as QuestionV2.Request).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
|
||||
return { fiber, request: yield* Deferred.await(asked) }
|
||||
})
|
||||
|
||||
describe("QuestionV2", () => {
|
||||
it.effect("publishes lifecycle events and settles a pending reply", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* QuestionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const published: EventV2.Payload[] = []
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type.startsWith("question.v2.")) published.push(event)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
|
||||
|
||||
expect(request.id).toMatch(/^que_/)
|
||||
expect(yield* service.list()).toEqual([request])
|
||||
yield* service.reply({ requestID: request.id, answers: [["One"]] })
|
||||
|
||||
expect(yield* Fiber.join(fiber)).toEqual([["One"]])
|
||||
expect(yield* service.list()).toEqual([])
|
||||
expect(published.map((event) => [event.type, event.data])).toEqual([
|
||||
[QuestionV2.Event.Asked.type, request],
|
||||
[QuestionV2.Event.Replied.type, { sessionID, requestID: request.id, answers: [["One"]] }],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes rejection, fails the ask, and rejects unknown IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* QuestionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const published: EventV2.Payload[] = []
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === QuestionV2.Event.Rejected.type) published.push(event)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
|
||||
|
||||
yield* service.reject(request.id)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
|
||||
expect(published.map((event) => event.data)).toEqual([{ sessionID, requestID: request.id }])
|
||||
|
||||
const unknown = QuestionV2.ID.ascending("que_unknown")
|
||||
expect(yield* service.reply({ requestID: unknown, answers: [] }).pipe(Effect.flip)).toEqual(
|
||||
new QuestionV2.NotFoundError({ requestID: unknown }),
|
||||
)
|
||||
expect(yield* service.reject(unknown).pipe(Effect.flip)).toEqual(
|
||||
new QuestionV2.NotFoundError({ requestID: unknown }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("isolates pending requests by location-layer instance and rejects them on finalization", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstScope = yield* Scope.make()
|
||||
const secondScope = yield* Scope.make()
|
||||
const first = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), firstScope), QuestionV2.Service)
|
||||
const second = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), secondScope), QuestionV2.Service)
|
||||
const fiber = yield* first.ask({ sessionID, questions: [question] }).pipe(Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
const request = (yield* first.list())[0]!
|
||||
|
||||
expect(yield* second.list()).toEqual([])
|
||||
expect(yield* second.reply({ requestID: request.id, answers: [["One"]] }).pipe(Effect.flip)).toEqual(
|
||||
new QuestionV2.NotFoundError({ requestID: request.id }),
|
||||
)
|
||||
|
||||
yield* Scope.close(firstScope, Exit.void)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
|
||||
yield* Scope.close(secondScope, Exit.void)
|
||||
}),
|
||||
)
|
||||
})
|
||||
125
packages/core/test/repository-cache.test.ts
Normal file
125
packages/core/test/repository-cache.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { git, gitRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("RepositoryCache", () => {
|
||||
it.live("replaces a stale cache directory before cloning", () =>
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const localPath = Repository.cachePath(path.join(fixture.root, "repos"), fixture.reference)
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(localPath, { recursive: true })
|
||||
await fs.writeFile(path.join(localPath, "stale.txt"), "stale")
|
||||
})
|
||||
|
||||
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
|
||||
|
||||
expect(result.status).toBe("cloned")
|
||||
expect(yield* exists(path.join(localPath, "stale.txt"))).toBe(false)
|
||||
expect(yield* read(path.join(localPath, "README.md"))).toBe("one\n")
|
||||
}).pipe(Effect.provide(cacheLayer(fixture.root))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent materialization for the same checkout", () =>
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const results = yield* Effect.all(
|
||||
[cache.ensure({ reference: fixture.reference }), cache.ensure({ reference: fixture.reference })],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(results.map((result) => result.status).toSorted()).toEqual(["cached", "cloned"])
|
||||
expect(results[0].localPath).toBe(results[1].localPath)
|
||||
}).pipe(Effect.provide(cacheLayer(fixture.root))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("replaces an existing checkout whose origin does not match", () =>
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const initial = yield* cache.ensure({ reference: fixture.reference })
|
||||
yield* Effect.promise(async () => {
|
||||
await git(initial.localPath, "config", "remote.origin.url", "https://github.com/other/repo.git")
|
||||
await fs.writeFile(path.join(initial.localPath, "stale.txt"), "stale")
|
||||
})
|
||||
|
||||
const replaced = yield* cache.ensure({ reference: fixture.reference })
|
||||
|
||||
expect(replaced.status).toBe("cloned")
|
||||
expect(yield* exists(path.join(replaced.localPath, "stale.txt"))).toBe(false)
|
||||
}).pipe(Effect.provide(cacheLayer(fixture.root))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns typed validation and clone failures", () =>
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const invalidRepository = yield* Effect.flip(RepositoryCache.parseRemote("not-a-repo"))
|
||||
expect(invalidRepository).toBeInstanceOf(RepositoryCache.InvalidRepositoryError)
|
||||
|
||||
const invalidBranch = yield* Effect.flip(cache.ensure({ reference: fixture.reference, branch: "../unsafe" }))
|
||||
expect(invalidBranch).toBeInstanceOf(RepositoryCache.InvalidBranchError)
|
||||
|
||||
const cloneFailure = yield* Effect.flip(
|
||||
cache.ensure({
|
||||
reference: { ...fixture.reference, remote: pathToFileURL(path.join(fixture.root, "missing.git")).href },
|
||||
}),
|
||||
)
|
||||
expect(cloneFailure).toBeInstanceOf(RepositoryCache.CloneFailedError)
|
||||
}).pipe(Effect.provide(cacheLayer(fixture.root))),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function cacheLayer(root: string) {
|
||||
const dependencies = Layer.mergeAll(
|
||||
Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") }),
|
||||
FSUtil.defaultLayer,
|
||||
)
|
||||
return RepositoryCache.layer.pipe(
|
||||
Layer.provide(EffectFlock.layer.pipe(Layer.provide(dependencies))),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(dependencies),
|
||||
)
|
||||
}
|
||||
|
||||
function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.promise(async () => {
|
||||
const root = await tmpdir()
|
||||
return { root, fixture: await gitRemote(root.path) }
|
||||
}),
|
||||
(input) => body(input.fixture),
|
||||
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
|
||||
)
|
||||
}
|
||||
|
||||
function read(file: string) {
|
||||
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
|
||||
}
|
||||
|
||||
function exists(file: string) {
|
||||
return Effect.promise(() =>
|
||||
fs.stat(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
)
|
||||
}
|
||||
65
packages/core/test/repository.test.ts
Normal file
65
packages/core/test/repository.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
|
||||
describe("Repository", () => {
|
||||
test("parses github shorthand and builds an explicit-root cache path", () => {
|
||||
const reference = Repository.parseRemote("owner/repo")
|
||||
|
||||
expect(reference).toMatchObject({
|
||||
host: "github.com",
|
||||
path: "owner/repo",
|
||||
segments: ["owner", "repo"],
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
remote: "https://github.com/owner/repo.git",
|
||||
label: "owner/repo",
|
||||
})
|
||||
expect(Repository.cachePath("/cache", reference)).toBe(path.join("/cache", "github.com", "owner", "repo"))
|
||||
expect(Repository.cacheIdentity(reference)).toBe("github.com/owner/repo")
|
||||
})
|
||||
|
||||
test("parses host path and scp remote references", () => {
|
||||
expect(Repository.parseRemote("gitlab.com/group/repo")).toMatchObject({
|
||||
host: "gitlab.com",
|
||||
path: "group/repo",
|
||||
remote: "https://gitlab.com/group/repo.git",
|
||||
label: "gitlab.com/group/repo",
|
||||
})
|
||||
expect(Repository.parseRemote("git@github.com:owner/repo.git")).toMatchObject({
|
||||
host: "github.com",
|
||||
path: "owner/repo",
|
||||
remote: "git@github.com:owner/repo.git",
|
||||
label: "owner/repo",
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps local file repositories distinct from remote repositories", () => {
|
||||
const localPath = path.resolve("repo.git")
|
||||
const reference = Repository.parse(pathToFileURL(localPath).href)
|
||||
|
||||
expect(reference).toMatchObject({ host: "file", protocol: "file:", label: localPath })
|
||||
expect(reference && Repository.isFile(reference)).toBe(true)
|
||||
expect(reference && Repository.isRemote(reference)).toBe(false)
|
||||
expect(() => Repository.parseRemote(pathToFileURL(localPath).href)).toThrow(
|
||||
Repository.UnsupportedLocalRepositoryError,
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects unsafe remote references and branches with typed errors", () => {
|
||||
expect(() => Repository.parseRemote("not-a-repo")).toThrow(Repository.InvalidReferenceError)
|
||||
expect(() => Repository.parseRemote("git@github.com:../../../etc/passwd")).toThrow(Repository.InvalidReferenceError)
|
||||
expect(() => Repository.validateBranch("feature/docs.v1")).not.toThrow()
|
||||
expect(() => Repository.validateBranch("-bad")).toThrow(Repository.InvalidBranchError)
|
||||
expect(() => Repository.validateBranch("bad..branch")).toThrow(Repository.InvalidBranchError)
|
||||
expect(() => Repository.validateBranch("bad branch")).toThrow(Repository.InvalidBranchError)
|
||||
})
|
||||
|
||||
test("compares cache identity independent of input spelling", () => {
|
||||
const shorthand = Repository.parseRemote("owner/repo")
|
||||
|
||||
expect(Repository.same(shorthand, Repository.parseRemote("https://github.com/owner/repo.git"))).toBe(true)
|
||||
expect(Repository.same(shorthand, Repository.parseRemote("github.com/owner/repo"))).toBe(true)
|
||||
})
|
||||
})
|
||||
259
packages/core/test/session-create.test.ts
Normal file
259
packages/core/test/session-create.test.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
|
||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(events),
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
Layer.provide(projects),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(database, events, projects, projector, store, SessionExecution.noopLayer, sessions),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const id = SessionV2.ID.create()
|
||||
|
||||
describe("SessionV2.create", () => {
|
||||
it.effect("derives stable namespaced external IDs", () =>
|
||||
Effect.sync(() => {
|
||||
const input = { namespace: "opencord.agent-thread", key: "thread-1" }
|
||||
|
||||
expect(SessionV2.ID.fromExternal(input)).toBe(SessionV2.ID.fromExternal(input))
|
||||
expect(SessionV2.ID.fromExternal(input)).toMatch(/^ses_[a-f0-9]{64}$/)
|
||||
expect(SessionV2.ID.fromExternal({ ...input, namespace: "another-app" })).not.toBe(
|
||||
SessionV2.ID.fromExternal(input),
|
||||
)
|
||||
expect(SessionV2.ID.fromExternal({ namespace: "a:b", key: "c" })).not.toBe(
|
||||
SessionV2.ID.fromExternal({ namespace: "a", key: "b:c" }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates a fresh projected session when the ID is omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
const first = yield* session.create({ location })
|
||||
const second = yield* session.create({ location })
|
||||
|
||||
expect(second.id).not.toBe(first.id)
|
||||
expect(yield* session.list()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the original session when the ID is retried", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const input = { id, location }
|
||||
|
||||
const first = yield* session.create(input)
|
||||
const retried = yield* session.create(input)
|
||||
|
||||
expect(retried).toEqual(first)
|
||||
expect(yield* session.list()).toEqual([first])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stores supplied immutable create attributes", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const workspaceID = WorkspaceV2.ID.make("wrk_test")
|
||||
const model = ModelV2.Ref.make({
|
||||
id: ModelV2.ID.make("sonnet"),
|
||||
providerID: ProviderV2.ID.anthropic,
|
||||
variant: ModelV2.VariantID.make("fast"),
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* session.create({
|
||||
location: Location.Ref.make({ directory: location.directory, workspaceID }),
|
||||
agent: AgentV2.ID.make("build"),
|
||||
model,
|
||||
}),
|
||||
).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the existing Session when one ID is reused with different create arguments", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ id, location })
|
||||
const changed = [
|
||||
{ id, location: Location.Ref.make({ directory: AbsolutePath.make("/other") }) },
|
||||
{ id, location, agent: AgentV2.ID.make("build") },
|
||||
{
|
||||
id,
|
||||
location,
|
||||
model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
|
||||
},
|
||||
]
|
||||
|
||||
for (const input of changed) {
|
||||
expect(yield* session.create(input)).toEqual(created)
|
||||
}
|
||||
expect(yield* session.list()).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns one recorded session to concurrent exact retries", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const input = { id, location }
|
||||
|
||||
const created = yield* Effect.all([session.create(input), session.create(input)], { concurrency: "unbounded" })
|
||||
|
||||
expect(created[1]).toEqual(created[0])
|
||||
expect(yield* session.list()).toEqual([created[0]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the current Session projection after updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const input = { id, location }
|
||||
const created = yield* session.create(input)
|
||||
|
||||
yield* db.update(SessionTable).set({ agent: "build" }).where(eq(SessionTable.id, id)).run().pipe(Effect.orDie)
|
||||
|
||||
expect(yield* session.create(input)).toMatchObject({ id: created.id, agent: "build" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the current Session projection after projected updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const input = { id, location }
|
||||
const created = yield* session.create(input)
|
||||
|
||||
yield* events.publish(SessionV1.Event.Updated, {
|
||||
sessionID: id,
|
||||
info: SessionV1.SessionInfo.make({
|
||||
id,
|
||||
slug: "updated",
|
||||
version: "test",
|
||||
projectID: created.projectID,
|
||||
directory: created.location.directory,
|
||||
title: "updated",
|
||||
agent: "build",
|
||||
time: { created: 0, updated: 1 },
|
||||
}),
|
||||
})
|
||||
|
||||
expect(yield* session.create(input)).toMatchObject({ id, agent: "build" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists creation through the existing legacy created event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
expect(
|
||||
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
|
||||
).toMatchObject([{ type: EventV2.versionedType(SessionV1.Event.Created.type, 1) }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists caller-ID creation through the existing created event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const created = yield* session.create({ id, location })
|
||||
|
||||
expect(
|
||||
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).get().pipe(Effect.orDie),
|
||||
).toMatchObject({
|
||||
data: { sessionID: id },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits legacy creation rows from the V2 Session event stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.prompt({ sessionID: created.id, prompt: new Prompt({ text: "Hello" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, created.id)
|
||||
|
||||
expect(
|
||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ cursor: 1, event: { type: "session.next.prompted", data: { prompt: { text: "Hello" } } } }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not mask unrelated created projector defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const event = yield* EventV2.Service
|
||||
const defect = new Error("unrelated projector defect")
|
||||
yield* event.project(SessionV1.Event.Created, () => Effect.die(defect))
|
||||
|
||||
expect(yield* session.create({ id, location }).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports unfinished Session operations as unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
const unavailable = (
|
||||
effect: Effect.Effect<void, SessionV2.NotFoundError | SessionV2.OperationUnavailableError>,
|
||||
) =>
|
||||
effect.pipe(
|
||||
Effect.flip,
|
||||
Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")),
|
||||
)
|
||||
|
||||
expect(yield* unavailable(session.move({ sessionID: created.id, location }))).toBe("move")
|
||||
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
|
||||
expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
|
||||
expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent")
|
||||
expect(
|
||||
yield* unavailable(
|
||||
session.switchModel({
|
||||
sessionID: created.id,
|
||||
model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
|
||||
}),
|
||||
),
|
||||
).toBe("switchModel")
|
||||
}),
|
||||
)
|
||||
})
|
||||
458
packages/core/test/session-projector.test.ts
Normal file
458
packages/core/test/session-projector.test.ts
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
|
||||
const it = testEffect(Layer.mergeAll(database, events, projector))
|
||||
const sessionID = SessionV2.ID.make("ses_projector_test")
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
||||
|
||||
const assistantRow = (
|
||||
id: SessionMessage.ID,
|
||||
seq: number,
|
||||
time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created },
|
||||
) => {
|
||||
const {
|
||||
id: _,
|
||||
type,
|
||||
...data
|
||||
} = encodeMessage(new SessionMessage.Assistant({ id, type: "assistant", agent: "build", model, content: [], time }))
|
||||
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
|
||||
}
|
||||
|
||||
describe("SessionProjector", () => {
|
||||
it.effect("orders projected messages and context by durable aggregate sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: created, prompt: new Prompt({ text: "first" }), delivery: "steer" },
|
||||
{ id: SessionMessage.ID.make("evt_z") },
|
||||
)
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: created, prompt: new Prompt({ text: "second" }), delivery: "steer" },
|
||||
{ id: SessionMessage.ID.make("evt_a") },
|
||||
)
|
||||
|
||||
const sessions = yield* SessionV2.Service
|
||||
const firstPage = yield* sessions.messages({ sessionID, limit: 1, order: "asc" })
|
||||
expect(firstPage.map((message) => (message.type === "user" ? message.text : message.type))).toEqual(["first"])
|
||||
const secondPage = yield* sessions.messages({
|
||||
sessionID,
|
||||
limit: 1,
|
||||
order: "asc",
|
||||
cursor: { id: firstPage[0]!.id, direction: "next" },
|
||||
})
|
||||
expect(secondPage.map((message) => (message.type === "user" ? message.text : message.type))).toEqual(["second"])
|
||||
expect(
|
||||
(yield* sessions.messages({
|
||||
sessionID,
|
||||
limit: 1,
|
||||
order: "asc",
|
||||
cursor: { id: secondPage[0]!.id, direction: "previous" },
|
||||
})).map((message) => (message.type === "user" ? message.text : message.type)),
|
||||
).toEqual(["first"])
|
||||
expect(
|
||||
(yield* sessions.context(sessionID)).map((message) => (message.type === "user" ? message.text : message.type)),
|
||||
).toEqual(["first", "second"])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
SessionV2.layer.pipe(
|
||||
Layer.provide(events),
|
||||
Layer.provide(database),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(SessionStore.layer.pipe(Layer.provide(database))),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("marks an admitted inbox row promoted with the Prompted event sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const id = SessionMessage.ID.make("evt_admitted")
|
||||
yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "promote me" }), delivery: "steer" })
|
||||
|
||||
const event = yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: created, prompt: new Prompt({ text: "promote me" }), delivery: "steer" },
|
||||
{ id },
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
|
||||
).toMatchObject({ promoted_seq: event.seq })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects durable context messages supported by the updater", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
yield* events.publish(SessionEvent.AgentSwitched, { sessionID, timestamp: created, agent: "build" })
|
||||
yield* events.publish(SessionEvent.ModelSwitched, { sessionID, timestamp: created, model })
|
||||
yield* events.publish(SessionEvent.Synthetic, { sessionID, timestamp: created, text: "synthetic context" })
|
||||
yield* events.publish(SessionEvent.Shell.Started, {
|
||||
sessionID,
|
||||
timestamp: created,
|
||||
callID: "shell-1",
|
||||
command: "pwd",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Shell.Ended, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
callID: "shell-1",
|
||||
output: "/project",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Compaction.Started, { sessionID, timestamp: created, reason: "manual" })
|
||||
yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, timestamp: created, text: "partial" })
|
||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
text: "summary",
|
||||
include: "msg-1",
|
||||
})
|
||||
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const messages = rows.map((row) =>
|
||||
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
|
||||
)
|
||||
|
||||
expect(messages.map((message) => message.type)).toEqual([
|
||||
"agent-switched",
|
||||
"model-switched",
|
||||
"synthetic",
|
||||
"shell",
|
||||
"compaction",
|
||||
])
|
||||
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
||||
output: "/project",
|
||||
time: { completed: DateTime.makeUnsafe(1) },
|
||||
})
|
||||
expect(messages.find((message) => message.type === "compaction")).toMatchObject({
|
||||
summary: "summary",
|
||||
include: "msg-1",
|
||||
})
|
||||
expect(
|
||||
yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
|
||||
).toMatchObject({
|
||||
agent: "build",
|
||||
model,
|
||||
time_updated: DateTime.toEpochMillis(created),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a Prompted event that conflicts with an admitted inbox row", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const id = SessionMessage.ID.make("evt_conflict")
|
||||
yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "admitted" }), delivery: "steer" })
|
||||
|
||||
const exit = yield* events
|
||||
.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: created, prompt: new Prompt({ text: "different" }), delivery: "steer" },
|
||||
{ id },
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(String(exit)).toContain("Prompt projection conflicts with admitted input")
|
||||
expect(
|
||||
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
|
||||
).toMatchObject({ promoted_seq: null })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a Prompted delivery mode that conflicts with an admitted inbox row", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const id = SessionMessage.ID.make("evt_delivery_conflict")
|
||||
const prompt = new Prompt({ text: "admitted" })
|
||||
yield* SessionInput.admit(db, { id, sessionID, prompt, delivery: "queue" })
|
||||
|
||||
const exit = yield* events
|
||||
.publish(SessionEvent.Prompted, { sessionID, timestamp: created, prompt, delivery: "steer" }, { id })
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(String(exit)).toContain("Prompt projection conflicts with admitted input")
|
||||
expect(
|
||||
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
|
||||
).toMatchObject({ delivery: "queue", promoted_seq: null })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const stale = new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.make("evt_assistant_stale"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
content: [],
|
||||
time: { created },
|
||||
})
|
||||
const completed = new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.make("evt_assistant_completed"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
content: [],
|
||||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* SessionMessageUpdater.memory({ messages: [stale, completed] }).getCurrentAssistant(),
|
||||
).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("updates only the newest incomplete assistant projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
assistantRow(SessionMessage.ID.make("evt_assistant_1"), 0),
|
||||
assistantRow(SessionMessage.ID.make("evt_assistant_2"), 1),
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const service = yield* EventV2.Service
|
||||
yield* service.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
assistantMessageID: SessionMessage.ID.make("evt_assistant_2"),
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||
.orderBy(asc(SessionMessageTable.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const messages = rows.map((row) =>
|
||||
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
|
||||
)
|
||||
expect(messages[0]).not.toHaveProperty("time.completed")
|
||||
expect(messages[1]).toMatchObject({
|
||||
type: "assistant",
|
||||
finish: "stop",
|
||||
time: { completed: DateTime.makeUnsafe(1) },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not revive a stale incomplete assistant projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
assistantRow(SessionMessage.ID.make("evt_assistant_stale"), 0),
|
||||
assistantRow(SessionMessage.ID.make("evt_assistant_completed"), 1, {
|
||||
created: DateTime.makeUnsafe(1),
|
||||
completed: DateTime.makeUnsafe(2),
|
||||
}),
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const service = yield* EventV2.Service
|
||||
yield* service.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(3),
|
||||
textID: "text-stale",
|
||||
})
|
||||
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||
.orderBy(asc(SessionMessageTable.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const messages = rows.map((row) =>
|
||||
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
|
||||
)
|
||||
expect(messages).toEqual([
|
||||
new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.make("evt_assistant_completed"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
content: [],
|
||||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
||||
}),
|
||||
new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.make("evt_assistant_stale"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
content: [],
|
||||
time: { created },
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
463
packages/core/test/session-prompt.test.ts
Normal file
463
packages/core/test/session-prompt.test.ts
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { SessionInputTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
|
||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
||||
const executionCalls: SessionV2.ID[] = []
|
||||
const wakeCalls: SessionV2.ID[] = []
|
||||
const execution = Layer.succeed(
|
||||
SessionExecution.Service,
|
||||
SessionExecution.Service.of({
|
||||
resume: (sessionID) =>
|
||||
Effect.sync(() => {
|
||||
executionCalls.push(sessionID)
|
||||
}),
|
||||
wake: (sessionID) =>
|
||||
Effect.sync(() => {
|
||||
wakeCalls.push(sessionID)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(events),
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(execution),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(database, events, projector, store, execution, sessions))
|
||||
const sessionID = SessionV2.ID.make("ses_prompt_test")
|
||||
const messageID = SessionMessage.ID.create()
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionInput.find(db, id))
|
||||
const admittedCount = Database.Service.use(({ db }) =>
|
||||
db
|
||||
.select()
|
||||
.from(SessionInputTable)
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows.length),
|
||||
),
|
||||
)
|
||||
|
||||
describe("SessionV2.prompt", () => {
|
||||
it.effect("delegates execution continuation through SessionExecution", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
executionCalls.length = 0
|
||||
wakeCalls.length = 0
|
||||
yield* session.resume(sessionID)
|
||||
expect(executionCalls).toEqual([sessionID])
|
||||
expect(wakeCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("durably admits one user message before transcript promotion", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: new Prompt({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.type).toBe("user")
|
||||
expect(message.text).toBe("Fix the failing tests")
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect(yield* admitted(message.id)).toMatchObject({
|
||||
id: message.id,
|
||||
sessionID,
|
||||
prompt: { text: "Fix the failing tests" },
|
||||
delivery: "steer",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams durable Session events after an aggregate cursor", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const fiber = yield* session.events({ sessionID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
||||
const streamed = Array.from(yield* Fiber.join(fiber))
|
||||
|
||||
expect(
|
||||
streamed.map((event) => [event.cursor, event.event.type, (event.event.data as { prompt: Prompt }).prompt.text]),
|
||||
).toEqual([
|
||||
[EventV2.Cursor.make(0), "session.next.prompted", "First"],
|
||||
[EventV2.Cursor.make(1), "session.next.prompted", "Second"],
|
||||
])
|
||||
expect(
|
||||
Array.from(
|
||||
yield* session.events({ sessionID, after: streamed[0]!.cursor }).pipe(Stream.take(1), Stream.runCollect),
|
||||
).map((event) => [event.cursor, (event.event.data as { prompt: Prompt }).prompt.text]),
|
||||
).toEqual([[EventV2.Cursor.make(1), "Second"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resumes through a recorded message without appending another prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: new Prompt({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
executionCalls.length = 0
|
||||
wakeCalls.length = 0
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect(yield* admitted(message.id)).not.toHaveProperty("promotedSeq")
|
||||
expect(executionCalls).toEqual([sessionID])
|
||||
expect(wakeCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("records distinct messages when the ID is omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const input = { sessionID, prompt: new Prompt({ text: "Fix the failing tests" }), resume: false }
|
||||
|
||||
const first = yield* session.prompt(input)
|
||||
const second = yield* session.prompt(input)
|
||||
|
||||
expect(second.id).not.toBe(first.id)
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect(yield* admittedCount).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the original recorded message when the ID is retried", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const input = {
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: new Prompt({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
}
|
||||
|
||||
const first = yield* session.prompt(input)
|
||||
const retried = yield* session.prompt(input)
|
||||
|
||||
expect(retried).toEqual(first)
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect(yield* admittedCount).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("wakes execution when an exact prompt retry recovers a committed message", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const input = {
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: new Prompt({ text: "Recover committed prompt" }),
|
||||
resume: false,
|
||||
}
|
||||
const first = yield* session.prompt(input)
|
||||
wakeCalls.length = 0
|
||||
|
||||
const retried = yield* session.prompt({ ...input, resume: true })
|
||||
|
||||
expect(retried).toEqual(first)
|
||||
expect(wakeCalls).toEqual([sessionID])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reuse of one ID with a different prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: new Prompt({ text: "Fix the failing tests" }),
|
||||
})
|
||||
const failure = yield* session
|
||||
.prompt({
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: new Prompt({ text: "Delete the failing tests" }),
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(failure._tag).toBe("Session.PromptConflictError")
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(0)
|
||||
expect(yield* admittedCount).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reuse of one ID with a different delivery mode", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
yield* session.prompt({
|
||||
id: messageID,
|
||||
sessionID,
|
||||
prompt: new Prompt({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
})
|
||||
const failure = yield* session
|
||||
.prompt({
|
||||
id: messageID,
|
||||
sessionID,
|
||||
prompt: new Prompt({ text: "Fix the failing tests" }),
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(failure._tag).toBe("Session.PromptConflictError")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not match pending inputs when no delivery modes are eligible", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Wait" }), resume: false })
|
||||
|
||||
expect(yield* SessionInput.hasPending(db, sessionID, [])).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns one recorded message to concurrent exact retries", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const input = {
|
||||
sessionID,
|
||||
id: messageID,
|
||||
prompt: new Prompt({ text: "Fix the failing tests" }),
|
||||
resume: false,
|
||||
}
|
||||
|
||||
const messages = yield* Effect.all([session.prompt(input), session.prompt(input)], { concurrency: "unbounded" })
|
||||
|
||||
expect(messages[1]).toEqual(messages[0])
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect(yield* admittedCount).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles an existing projected prompt into a promoted inbox record", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const prompt = new Prompt({ text: "Historical prompt" })
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: yield* DateTime.now, prompt, delivery: "steer" },
|
||||
{ id: messageID },
|
||||
)
|
||||
|
||||
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
|
||||
|
||||
expect(retried).toMatchObject({ id: messageID, text: "Historical prompt" })
|
||||
expect(yield* admitted(messageID)).toHaveProperty("promotedSeq")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles an existing projected queued prompt with its delivery mode", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const prompt = new Prompt({ text: "Historical queued prompt" })
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: yield* DateTime.now, prompt, delivery: "queue" },
|
||||
{ id: messageID },
|
||||
)
|
||||
|
||||
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, delivery: "queue", resume: false })
|
||||
|
||||
expect(retried).toMatchObject({ id: messageID, text: "Historical queued prompt" })
|
||||
expect(yield* admitted(messageID)).toMatchObject({ delivery: "queue" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects an input ID already used by a durable non-prompt event", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(
|
||||
SessionEvent.Synthetic,
|
||||
{ sessionID, timestamp: yield* DateTime.now, text: "Collision" },
|
||||
{ id: messageID },
|
||||
)
|
||||
|
||||
const failure = yield* session
|
||||
.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Collision" }), resume: false })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(failure._tag).toBe("Session.PromptConflictError")
|
||||
expect(yield* admitted(messageID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a durable event ID reserved by an admitted prompt without poisoning promotion", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const prompt = new Prompt({ text: "Reserved prompt" })
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
|
||||
|
||||
const failure = yield* events
|
||||
.publish(
|
||||
SessionEvent.Synthetic,
|
||||
{ sessionID, timestamp: yield* DateTime.now, text: "Conflicting synthetic" },
|
||||
{ id: messageID },
|
||||
)
|
||||
.pipe(Effect.catchDefect(Effect.succeed))
|
||||
|
||||
expect(failure).toBe("Durable event conflicts with admitted prompt input")
|
||||
expect(yield* admitted(messageID)).not.toHaveProperty("promotedSeq")
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
||||
|
||||
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 0 })
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||
{ id: messageID, type: "user", text: "Reserved prompt" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reuse of one globally unique message ID across sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const other = SessionV2.ID.make("ses_prompt_other")
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: other,
|
||||
project_id: Project.ID.global,
|
||||
slug: "other",
|
||||
directory: "/project",
|
||||
title: "other",
|
||||
version: "test",
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const prompt = new Prompt({ text: "Fix the failing tests" })
|
||||
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
|
||||
const failure = yield* session
|
||||
.prompt({ id: messageID, sessionID: other, prompt, resume: false })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID: other, messageID })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts execution by default after recording the prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
executionCalls.length = 0
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) })
|
||||
|
||||
expect(executionCalls).toEqual([])
|
||||
expect(wakeCalls).toEqual([sessionID])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts execution when resume is explicitly true", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
executionCalls.length = 0
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run explicitly" }), resume: true })
|
||||
|
||||
expect(executionCalls).toEqual([])
|
||||
expect(wakeCalls).toEqual([sessionID])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("only records the prompt when resume is false", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
executionCalls.length = 0
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Do not run" }), resume: false })
|
||||
|
||||
expect(executionCalls).toEqual([])
|
||||
expect(wakeCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
384
packages/core/test/session-run-coordinator.test.ts
Normal file
384
packages/core/test/session-run-coordinator.test.ts
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("SessionRunCoordinator", () => {
|
||||
it.effect("joins concurrent resumes for one key", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
let runs = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () => Effect.sync(() => runs++).pipe(Effect.andThen(Deferred.await(gate))),
|
||||
})
|
||||
|
||||
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
const second = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(runs).toBe(1)
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(runs).toBe(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("starts a drain when woken while idle", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const drained = yield* Deferred.make<void>()
|
||||
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Deferred.succeed(drained, undefined) })
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.await(drained)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("coalesces wakes received during an active run", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
let runs = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () =>
|
||||
Effect.sync(() => ++runs).pipe(Effect.flatMap((run) => (run === 1 ? Deferred.await(gate) : Effect.void))),
|
||||
})
|
||||
|
||||
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* Effect.all([coordinator.wake("session"), coordinator.wake("session"), coordinator.wake("session")], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
yield* Fiber.join(first)
|
||||
|
||||
expect(runs).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("waits for a coalesced ownership chain to become idle", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstGate = yield* Deferred.make<void>()
|
||||
const secondGate = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
const idleSettled = yield* Deferred.make<void>()
|
||||
let runs = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () =>
|
||||
Effect.sync(() => ++runs).pipe(
|
||||
Effect.flatMap((run) =>
|
||||
run === 1
|
||||
? Deferred.await(firstGate)
|
||||
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
const idle = yield* coordinator
|
||||
.awaitIdle("session")
|
||||
.pipe(Effect.andThen(Deferred.succeed(idleSettled, undefined)), Effect.forkChild)
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.succeed(firstGate, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
expect(yield* Deferred.isDone(idleSettled)).toBeFalse()
|
||||
yield* Deferred.succeed(secondGate, undefined)
|
||||
yield* Fiber.join(idle)
|
||||
|
||||
expect(runs).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("reports the first defect after a failed chain becomes idle", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstGate = yield* Deferred.make<void>()
|
||||
const secondGate = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
const defect = new Error("defect")
|
||||
let runs = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () =>
|
||||
Effect.sync(() => ++runs).pipe(
|
||||
Effect.flatMap((run) =>
|
||||
run === 1
|
||||
? Deferred.await(firstGate).pipe(Effect.andThen(Effect.die(defect)))
|
||||
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate))),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
const idle = yield* coordinator
|
||||
.awaitIdle("session")
|
||||
.pipe(Effect.catchDefect(Effect.succeed), Effect.forkChild({ startImmediately: true }))
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.succeed(firstGate, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
yield* Deferred.succeed(secondGate, undefined)
|
||||
|
||||
expect(yield* Fiber.join(idle)).toBe(defect)
|
||||
expect(runs).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("runs again when woken during the coalesced drain", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstGate = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
const secondGate = yield* Deferred.make<void>()
|
||||
let runs = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () =>
|
||||
Effect.sync(() => ++runs).pipe(
|
||||
Effect.flatMap((run) =>
|
||||
run === 1
|
||||
? Deferred.await(firstGate)
|
||||
: run === 2
|
||||
? Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(secondGate)))
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.succeed(firstGate, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.succeed(secondGate, undefined)
|
||||
yield* Fiber.join(first)
|
||||
|
||||
expect(runs).toBe(3)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("starts one successor after a wake races with failure", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
const failure = new Error("failed")
|
||||
let runs = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () =>
|
||||
Effect.sync(() => ++runs).pipe(
|
||||
Effect.flatMap((run) =>
|
||||
run === 1 ? Deferred.await(gate).pipe(Effect.andThen(Effect.fail(failure))) : Effect.void,
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(failure)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(runs).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("upgrades an active wake when an explicit run joins it", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const wakeStarted = yield* Deferred.make<void>()
|
||||
const wakeGate = yield* Deferred.make<void>()
|
||||
const modes: SessionRunCoordinator.Mode[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
|
||||
drain: (_key, mode) =>
|
||||
Effect.sync(() => modes.push(mode)).pipe(
|
||||
Effect.andThen(
|
||||
mode === "wake"
|
||||
? Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate)))
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.await(wakeStarted)
|
||||
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.succeed(wakeGate, undefined)
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(modes).toEqual(["wake", "run"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("upgrades a recursive wake drain when an explicit run joins it", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const runGate = yield* Deferred.make<void>()
|
||||
const wakeStarted = yield* Deferred.make<void>()
|
||||
const wakeGate = yield* Deferred.make<void>()
|
||||
const forcedStarted = yield* Deferred.make<void>()
|
||||
const modes: SessionRunCoordinator.Mode[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
|
||||
drain: (_key, mode) =>
|
||||
Effect.gen(function* () {
|
||||
modes.push(mode)
|
||||
if (modes.length === 1) return yield* Deferred.await(runGate)
|
||||
if (modes.length === 2)
|
||||
return yield* Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate)))
|
||||
yield* Deferred.succeed(forcedStarted, undefined)
|
||||
}),
|
||||
})
|
||||
|
||||
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.succeed(runGate, undefined)
|
||||
yield* Deferred.await(wakeStarted)
|
||||
const second = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.succeed(wakeGate, undefined)
|
||||
yield* Deferred.await(forcedStarted)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
|
||||
expect(modes).toEqual(["run", "wake", "run"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("propagates an upgraded explicit run failure before a successful advisory successor", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const wakeStarted = yield* Deferred.make<void>()
|
||||
const wakeGate = yield* Deferred.make<void>()
|
||||
const runStarted = yield* Deferred.make<void>()
|
||||
const runGate = yield* Deferred.make<void>()
|
||||
const advisoryStarted = yield* Deferred.make<void>()
|
||||
const failure = new Error("explicit run failed")
|
||||
const modes: SessionRunCoordinator.Mode[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, void, Error>({
|
||||
drain: (_key, mode) =>
|
||||
Effect.sync(() => modes.push(mode)).pipe(
|
||||
Effect.flatMap((run) =>
|
||||
run === 1
|
||||
? Deferred.succeed(wakeStarted, undefined).pipe(Effect.andThen(Deferred.await(wakeGate)))
|
||||
: run === 2
|
||||
? Deferred.succeed(runStarted, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(runGate)),
|
||||
Effect.andThen(Effect.fail(failure)),
|
||||
)
|
||||
: Deferred.succeed(advisoryStarted, undefined),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.await(wakeStarted)
|
||||
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.succeed(wakeGate, undefined)
|
||||
yield* Deferred.await(runStarted)
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.succeed(runGate, undefined)
|
||||
yield* Deferred.await(advisoryStarted)
|
||||
|
||||
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
|
||||
expect(modes).toEqual(["wake", "run", "wake"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("settles active callers when its owning scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.make()
|
||||
const started = yield* Deferred.make<void>()
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
}).pipe(Scope.provide(scope))
|
||||
|
||||
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
const idle = yield* coordinator.awaitIdle("session").pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
const runExit = yield* Fiber.await(run)
|
||||
const idleExit = yield* Fiber.await(idle)
|
||||
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
|
||||
expect(Exit.isSuccess(idleExit)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not start work after its owning scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.make()
|
||||
let runs = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () => Effect.sync(() => runs++),
|
||||
}).pipe(Scope.provide(scope))
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
yield* coordinator.awaitIdle("session")
|
||||
const runExit = yield* coordinator.run("session").pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
|
||||
expect(runs).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not cancel the owner when one joined waiter is interrupted", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
let runs = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () => Effect.sync(() => runs++).pipe(Effect.andThen(Deferred.await(gate))),
|
||||
})
|
||||
|
||||
const first = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
const second = yield* coordinator.run("session").pipe(Effect.forkChild)
|
||||
yield* Fiber.interrupt(second)
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
yield* Fiber.join(first)
|
||||
|
||||
expect(runs).toBe(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("runs different keys concurrently", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
const bothStarted = yield* Deferred.make<void>()
|
||||
let active = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () =>
|
||||
Effect.sync(() => ++active).pipe(
|
||||
Effect.tap(() => (active === 2 ? Deferred.succeed(bothStarted, undefined) : Effect.void)),
|
||||
Effect.andThen(Deferred.await(gate)),
|
||||
),
|
||||
})
|
||||
|
||||
const first = yield* coordinator.run("first").pipe(Effect.forkChild)
|
||||
const second = yield* coordinator.run("second").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(bothStarted)
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
377
packages/core/test/session-runner-message.test.ts
Normal file
377
packages/core/test/session-runner-message.test.ts
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Message, Model } from "@opencode-ai/llm"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { AgentAttachment, FileAttachment, ReferenceAttachment } from "@opencode-ai/core/session/prompt"
|
||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { DateTime } from "effect"
|
||||
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const id = (value: string) => EventV2.ID.make(`evt_${value}`)
|
||||
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
|
||||
|
||||
describe("toLLMMessages", () => {
|
||||
test("maps every top-level V2 Session message type", () => {
|
||||
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
|
||||
const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" })
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.AgentSwitched({
|
||||
id: id("agent"),
|
||||
type: "agent-switched",
|
||||
agent: "build",
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.ModelSwitched({
|
||||
id: id("model"),
|
||||
type: "model-switched",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.User({
|
||||
id: id("user"),
|
||||
type: "user",
|
||||
text: "Inspect this image",
|
||||
files: [file],
|
||||
agents: [new AgentAttachment({ name: "build" })],
|
||||
references: [reference],
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.Synthetic({
|
||||
id: id("synthetic"),
|
||||
type: "synthetic",
|
||||
sessionID: SessionV2.ID.make("ses_translate"),
|
||||
text: "Synthetic context",
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.Shell({
|
||||
id: id("shell"),
|
||||
type: "shell",
|
||||
callID: "shell-1",
|
||||
command: "pwd",
|
||||
output: "/project",
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
new SessionMessage.Compaction({
|
||||
id: id("compaction"),
|
||||
type: "compaction",
|
||||
reason: "auto",
|
||||
summary: "Earlier work",
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"])
|
||||
expect(messages[0]).toEqual(
|
||||
Message.make({
|
||||
id: id("user"),
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
|
||||
],
|
||||
metadata: { agents: [{ name: "build" }], references: [reference] },
|
||||
}),
|
||||
)
|
||||
expect(messages.slice(1).map((message) => message.content)).toEqual([
|
||||
[{ type: "text", text: "Synthetic context" }],
|
||||
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
|
||||
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
|
||||
])
|
||||
})
|
||||
|
||||
test("expands assistant tool calls and settled outcomes into canonical tool messages", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
id: id("assistant"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }),
|
||||
new SessionMessage.AssistantReasoning({
|
||||
type: "reasoning",
|
||||
id: "reasoning-1",
|
||||
text: "Think",
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "pending",
|
||||
name: "read",
|
||||
state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }),
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "running",
|
||||
name: "read",
|
||||
state: new SessionMessage.ToolStateRunning({
|
||||
status: "running",
|
||||
input: { path: "README.md" },
|
||||
content: [],
|
||||
structured: {},
|
||||
}),
|
||||
time: { created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "completed",
|
||||
name: "read",
|
||||
state: new SessionMessage.ToolStateCompleted({
|
||||
status: "completed",
|
||||
input: { path: "README.md" },
|
||||
content: [
|
||||
new ToolOutput.TextContent({ type: "text", text: "Hello" }),
|
||||
new ToolOutput.FileContent({
|
||||
type: "file",
|
||||
source: { type: "data", data: "aGVsbG8=" },
|
||||
mime: "image/png",
|
||||
name: "hello.png",
|
||||
}),
|
||||
],
|
||||
structured: {},
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { fake: { continuation: "hosted-call" } },
|
||||
resultMetadata: { fake: { continuation: "hosted-result" } },
|
||||
},
|
||||
state: new SessionMessage.ToolStateCompleted({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
content: [new ToolOutput.TextContent({ type: "text", text: "Found it" })],
|
||||
structured: {},
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
|
||||
state: new SessionMessage.ToolStateError({
|
||||
status: "error",
|
||||
input: { path: "README.md" },
|
||||
content: [],
|
||||
structured: {},
|
||||
error: { type: "unknown", message: "Denied" },
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Checking" },
|
||||
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
|
||||
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "completed",
|
||||
name: "read",
|
||||
input: { path: "README.md" },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "hosted-call" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "hosted-result" } },
|
||||
result: { type: "text", value: "Found it" },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
input: { path: "README.md" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "failed" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-failed",
|
||||
name: "write",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { continuation: "failed" } },
|
||||
result: {
|
||||
type: "error",
|
||||
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(messages[1]?.content).toEqual([
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "completed",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("restores OpenAI encrypted reasoning metadata", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
id: id("assistant-openai-reasoning"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
new SessionMessage.AssistantReasoning({
|
||||
type: "reasoning",
|
||||
id: "reasoning-openai",
|
||||
text: "Think",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Think",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("drops provider-native continuation metadata after a model switch", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
new SessionMessage.Assistant({
|
||||
id: id("assistant-old-model"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
|
||||
content: [
|
||||
new SessionMessage.AssistantReasoning({
|
||||
type: "reasoning",
|
||||
id: "reasoning-old-model",
|
||||
text: "Visible thought",
|
||||
providerMetadata: { anthropic: { signature: "sig_old" } },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "hosted-old-model",
|
||||
name: "web_search",
|
||||
provider: {
|
||||
executed: true,
|
||||
metadata: { openai: { itemId: "hosted-old-model" } },
|
||||
resultMetadata: { openai: { itemId: "hosted-old-model" } },
|
||||
},
|
||||
state: new SessionMessage.ToolStateCompleted({
|
||||
status: "completed",
|
||||
input: { query: "Effect" },
|
||||
content: [],
|
||||
structured: {},
|
||||
result: { type: "json", value: { status: "completed" } },
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
new SessionMessage.AssistantTool({
|
||||
type: "tool",
|
||||
id: "local-old-model",
|
||||
name: "read",
|
||||
provider: {
|
||||
executed: false,
|
||||
metadata: { fake: { call: "old" } },
|
||||
resultMetadata: { fake: { result: "old" } },
|
||||
},
|
||||
state: new SessionMessage.ToolStateCompleted({
|
||||
status: "completed",
|
||||
input: { path: "README.md" },
|
||||
content: [],
|
||||
structured: { text: "Hello" },
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Visible thought" },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-old-model",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-old-model",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { status: "completed" } },
|
||||
providerExecuted: true,
|
||||
cache: undefined,
|
||||
metadata: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "local-old-model",
|
||||
name: "read",
|
||||
input: { path: "README.md" },
|
||||
providerExecuted: false,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
expect(messages[1]?.content).toEqual([
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "local-old-model",
|
||||
name: "read",
|
||||
result: { type: "json", value: { text: "Hello" } },
|
||||
providerExecuted: false,
|
||||
cache: undefined,
|
||||
metadata: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
213
packages/core/test/session-runner-model.test.ts
Normal file
213
packages/core/test/session-runner-model.test.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { LLM } from "@opencode-ai/llm"
|
||||
import { LLMClient } from "@opencode-ai/llm/route"
|
||||
import { ConfigProvider, DateTime, Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
type Api =
|
||||
| {
|
||||
readonly type: "aisdk"
|
||||
readonly package: string
|
||||
readonly url?: string
|
||||
readonly settings?: Record<string, unknown>
|
||||
}
|
||||
| { readonly type: "native"; readonly url?: string; readonly settings: Record<string, unknown> }
|
||||
|
||||
const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
|
||||
new ModelV2.Info({
|
||||
id: ModelV2.ID.make("test-model"),
|
||||
providerID: ProviderV2.ID.make("test-provider"),
|
||||
name: "Test model",
|
||||
api: { id: ModelV2.ID.make("api-test-model"), ...api },
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
request: {
|
||||
headers: { "x-test": "header" },
|
||||
body: { store: false, apiKey: "secret" },
|
||||
},
|
||||
variants,
|
||||
time: { released: DateTime.makeUnsafe(0) },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 100, output: 20 },
|
||||
})
|
||||
|
||||
const provider = (api: ProviderV2.Info["api"]) =>
|
||||
new ProviderV2.Info({
|
||||
id: ProviderV2.ID.make("test-provider"),
|
||||
name: "Test provider",
|
||||
enabled: { via: "env", name: "TEST_PROVIDER_API_KEY" },
|
||||
env: ["TEST_PROVIDER_API_KEY"],
|
||||
api,
|
||||
request: { headers: {}, body: {} },
|
||||
})
|
||||
|
||||
describe("SessionRunnerModel", () => {
|
||||
it.effect("maps catalog OpenAI AI SDK models into native Responses routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
)
|
||||
|
||||
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
|
||||
expect(resolved.route).toMatchObject({
|
||||
id: "openai-responses",
|
||||
endpoint: { baseURL: "https://openai.example/v1" },
|
||||
defaults: {
|
||||
headers: { "x-test": "header" },
|
||||
limits: { context: 100, output: 20 },
|
||||
http: { body: { store: false } },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
)
|
||||
const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
|
||||
expect(JSON.stringify(prepared.body)).not.toContain("apiKey")
|
||||
expect(JSON.stringify(prepared.body)).not.toContain("secret")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
new ModelV2.Info({
|
||||
...model({
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://compatible.example/v1",
|
||||
settings: { apiKey: "settings-secret", compatibility: "strict" },
|
||||
}),
|
||||
request: { headers: {}, body: {} },
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
url: "https://compatible.example/v1/chat/completions",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(headers.authorization).toBe("Bearer settings-secret")
|
||||
expect(resolved.route.defaults.http?.body).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies the selected Session variant to request options", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
headers: { "x-variant": "high" },
|
||||
body: { reasoningEffort: "high" },
|
||||
},
|
||||
])
|
||||
const session = SessionV2.Info.make({
|
||||
id: SessionV2.ID.make("ses_model_variant"),
|
||||
projectID: ProjectV2.ID.global,
|
||||
title: "test",
|
||||
model: {
|
||||
id: catalog.id,
|
||||
providerID: catalog.providerID,
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
},
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
})
|
||||
|
||||
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
|
||||
|
||||
expect(resolved.route.defaults).toMatchObject({
|
||||
headers: { "x-test": "header", "x-variant": "high" },
|
||||
http: { body: { store: false, reasoningEffort: "high" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps catalog Anthropic AI SDK models into native routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }),
|
||||
)
|
||||
|
||||
expect(resolved.route).toMatchObject({
|
||||
id: "anthropic-messages",
|
||||
endpoint: { baseURL: "https://anthropic.example/v1" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves environment-backed bearer auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
new ModelV2.Info({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
}),
|
||||
provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const headers = yield* resolved.route.auth
|
||||
.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
url: "https://openai.example/v1/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
.pipe(
|
||||
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { TEST_PROVIDER_API_KEY: "secret" } }))),
|
||||
)
|
||||
|
||||
expect(headers.authorization).toBe("Bearer secret")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects catalog APIs without a native route", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model({ type: "aisdk", package: "@ai-sdk/google", url: "https://google.example/v1" }),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.UnsupportedApiError",
|
||||
providerID: "test-provider",
|
||||
modelID: "test-model",
|
||||
api: "aisdk:@ai-sdk/google",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports whether a catalog model has a supported native route", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
SessionRunnerModel.supported(
|
||||
model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
SessionRunnerModel.supported(
|
||||
model({ type: "aisdk", package: "@ai-sdk/google", url: "https://google.example/v1" }),
|
||||
),
|
||||
).toBe(false)
|
||||
expect(SessionRunnerModel.supported(model({ type: "native", settings: {} }))).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
155
packages/core/test/session-runner-recorded.test.ts
Normal file
155
packages/core/test/session-runner-recorded.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner"
|
||||
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "node:path"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
|
||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
||||
const cassette = HttpRecorder.cassetteLayer("session-runner/openai-chat-streams-text", {
|
||||
directory: path.resolve(import.meta.dir, "fixtures/recordings"),
|
||||
mode: process.env.RECORD === "true" ? "record" : "replay",
|
||||
}).pipe(Layer.provide(NodeFileSystem.layer))
|
||||
const executor = RequestExecutor.layer.pipe(Layer.provide(cassette))
|
||||
const client = LLMClient.layer.pipe(Layer.provide(executor))
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: () => Effect.die("unused"),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
|
||||
const model = OpenAIChat.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://api.openai.com/v1" },
|
||||
auth: Auth.bearer(process.env.OPENAI_API_KEY ?? "fixture"),
|
||||
generation: { maxTokens: 20, temperature: 0 },
|
||||
})
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
Layer.provide(events),
|
||||
Layer.provide(client),
|
||||
Layer.provide(registry),
|
||||
Layer.provide(models),
|
||||
)
|
||||
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
SessionRunCoordinator.Service.pipe(
|
||||
Effect.map((coordinator) => SessionExecution.Service.of({ resume: coordinator.run, wake: coordinator.wake })),
|
||||
),
|
||||
).pipe(Layer.provide(coordinator))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(events),
|
||||
Layer.provide(database),
|
||||
Layer.provide(store),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(execution),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
database,
|
||||
events,
|
||||
projector,
|
||||
store,
|
||||
executor,
|
||||
client,
|
||||
permission,
|
||||
registry,
|
||||
models,
|
||||
runner,
|
||||
coordinator,
|
||||
execution,
|
||||
sessions,
|
||||
),
|
||||
)
|
||||
const sessionID = SessionV2.ID.make("ses_runner_recorded")
|
||||
|
||||
describe("SessionRunnerLLM recorded", () => {
|
||||
it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const session = yield* SessionV2.Service
|
||||
const prompt = yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: new Prompt({ text: "Say hello in one short sentence." }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
const messages = yield* session.context(sessionID)
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0]).toEqual(prompt)
|
||||
expect(messages[1]).toMatchObject({ type: "assistant", agent: "build", finish: "stop" })
|
||||
expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([
|
||||
{ type: "text", text: "Hello!" },
|
||||
])
|
||||
expect(
|
||||
(yield* db
|
||||
.select({ type: EventTable.type })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.orderBy(EventTable.seq)
|
||||
.all()).map((event) => event.type),
|
||||
).toEqual([
|
||||
"session.next.prompted.1",
|
||||
"session.next.step.started.1",
|
||||
"session.next.text.started.1",
|
||||
"session.next.text.ended.1",
|
||||
"session.next.step.ended.2",
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
211
packages/core/test/session-runner-tool-registry.test.ts
Normal file
211
packages/core/test/session-runner-tool-registry.test.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Tool, ToolFailure } from "@opencode-ai/llm"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
|
||||
import { Effect, Exit, Layer, Schema, Scope } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
let denyAction: string | undefined
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
|
||||
const it = testEffect(Layer.mergeAll(permission, registry))
|
||||
|
||||
const echo = Tool.make({
|
||||
description: "Echo text",
|
||||
parameters: Schema.Struct({ text: Schema.String }),
|
||||
success: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.succeed({ text }),
|
||||
})
|
||||
|
||||
describe("ToolRegistry", () => {
|
||||
it.effect("rebuilds advertised definitions when a scoped transform closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
const transform = yield* registry.transform().pipe(Scope.provide(scope))
|
||||
|
||||
yield* transform((editor) => editor.set("echo", { tool: echo, authorize: () => Effect.void }))
|
||||
expect(yield* registry.definitions()).toMatchObject([{ name: "echo", description: "Echo text" }])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* registry.definitions()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns an error result for an unknown tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID: SessionV2.ID.make("ses_registry_test"),
|
||||
call: { type: "tool-call", id: "call-missing", name: "missing", input: {} },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unknown tool: missing" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not execute a tool when authorization fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
let executed = false
|
||||
const transform = yield* registry.transform()
|
||||
|
||||
yield* transform((editor) =>
|
||||
editor.set("denied", {
|
||||
authorize: () => Effect.fail(new ToolFailure({ message: "Denied" })),
|
||||
tool: Tool.make({
|
||||
description: "Denied tool",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () =>
|
||||
Effect.sync(() => {
|
||||
executed = true
|
||||
return { ok: true }
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID: SessionV2.ID.make("ses_registry_test"),
|
||||
call: { type: "tool-call", id: "call-denied", name: "denied", input: {} },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Denied" })
|
||||
expect(executed).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("binds invocation identity while preserving leaf-owned permission inputs", () =>
|
||||
Effect.gen(function* () {
|
||||
assertions.length = 0
|
||||
denyAction = undefined
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const transform = yield* registry.transform()
|
||||
const sessionID = SessionV2.ID.make("ses_registry_context")
|
||||
|
||||
yield* transform((editor) =>
|
||||
editor.set("context", {
|
||||
tool: Tool.make({
|
||||
description: "Context tool",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.Struct({ ok: Schema.Boolean }),
|
||||
}),
|
||||
execute: ({ assertPermission, call, source }) =>
|
||||
assertPermission({
|
||||
action: "inspect",
|
||||
resources: [call.id],
|
||||
save: ["*"],
|
||||
metadata: { tool: call.name },
|
||||
}).pipe(
|
||||
Effect.as({ ok: source === undefined }),
|
||||
Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" }))),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
|
||||
}),
|
||||
).toEqual({ type: "json", value: { ok: true } })
|
||||
expect(assertions).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
action: "inspect",
|
||||
resources: ["call-context"],
|
||||
save: ["*"],
|
||||
metadata: { tool: "context" },
|
||||
},
|
||||
])
|
||||
expect(assertions[0]).not.toHaveProperty("source")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps ordered multi-assert policy flow in the leaf and stops on denial", () =>
|
||||
Effect.gen(function* () {
|
||||
assertions.length = 0
|
||||
denyAction = "execute"
|
||||
let executed = false
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const transform = yield* registry.transform()
|
||||
|
||||
yield* transform((editor) =>
|
||||
editor.set("ordered", {
|
||||
tool: Tool.make({
|
||||
description: "Ordered policy tool",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.Struct({ ok: Schema.Boolean }),
|
||||
}),
|
||||
execute: ({ assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* assertPermission({ action: "external_directory", resources: ["/outside/*"] })
|
||||
yield* assertPermission({ action: "execute", resources: ["pwd"] })
|
||||
executed = true
|
||||
return { ok: true }
|
||||
}).pipe(Effect.catch(() => Effect.fail(new ToolFailure({ message: "Denied" })))),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID: SessionV2.ID.make("ses_registry_context"),
|
||||
call: { type: "tool-call", id: "call-ordered", name: "ordered", input: {} },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Denied" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "execute"])
|
||||
expect(executed).toBe(false)
|
||||
denyAction = undefined
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles encoded structured output with canonical projected content", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const transform = yield* registry.transform()
|
||||
|
||||
yield* transform((editor) =>
|
||||
editor.set("projected", {
|
||||
tool: Tool.make({
|
||||
description: "Projected tool",
|
||||
parameters: Schema.Struct({ prefix: Schema.String }),
|
||||
success: Schema.Struct({ count: Schema.NumberFromString }),
|
||||
execute: () => Effect.succeed({ count: 2 }),
|
||||
toModelOutput: ({ callID, parameters, output }) => [
|
||||
{ type: "text", text: `${callID}:${parameters.prefix}:${output.count}` },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* registry.settle({
|
||||
sessionID: SessionV2.ID.make("ses_registry_test"),
|
||||
call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } },
|
||||
}),
|
||||
).toEqual({
|
||||
result: { type: "text", value: "call-projected:count:2" },
|
||||
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
2121
packages/core/test/session-runner.test.ts
Normal file
2121
packages/core/test/session-runner.test.ts
Normal file
File diff suppressed because it is too large
Load diff
70
packages/core/test/session-system-context.test.ts
Normal file
70
packages/core/test/session-system-context.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionSystemContext } from "@opencode-ai/core/session-system-context"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const directory = AbsolutePath.make("/repo/packages/core")
|
||||
const projectDirectory = AbsolutePath.make("/repo")
|
||||
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
|
||||
const localDate = (time: number) => new Date(time).toDateString()
|
||||
const it = testEffect(
|
||||
SessionSystemContext.locationLayer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory }, { projectDirectory, vcs: { type: "git", store: AbsolutePath.make("/repo/.git") } }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
describe("SessionSystemContext", () => {
|
||||
it.effect("loads location-scoped environment and host-local date context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SessionSystemContext.Service
|
||||
const initialized = SystemContext.initialize(yield* context.load())
|
||||
|
||||
expect(initialized.baseline).toEqual([
|
||||
{
|
||||
key: SystemContext.Key.make("core/environment"),
|
||||
text: [
|
||||
"Here is some useful information about the environment you are running in:",
|
||||
"<env>",
|
||||
` Working directory: ${directory}`,
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
"</env>",
|
||||
].join("\n"),
|
||||
},
|
||||
{ key: SystemContext.Key.make("core/date"), text: `Today's date: ${localDate(timestamp)}` },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("refreshes the date without repeating unchanged environment context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SessionSystemContext.Service
|
||||
const initialized = SystemContext.initialize(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
|
||||
const refreshed = SystemContext.refresh(yield* context.load(), initialized.checkpoint)
|
||||
|
||||
expect(refreshed.changes).toEqual([
|
||||
{
|
||||
key: SystemContext.Key.make("core/date"),
|
||||
text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
95
packages/core/test/session-todo.test.ts
Normal file
95
packages/core/test/session-todo.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { asc } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionTable, TodoTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionTodo } from "@opencode-ai/core/session/todo"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const todos = SessionTodo.layer.pipe(Layer.provide(database), Layer.provide(events))
|
||||
const it = testEffect(Layer.mergeAll(database, events, todos))
|
||||
const sessionID = SessionV2.ID.make("ses_todo_test")
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "todo",
|
||||
directory: "/project",
|
||||
title: "todo",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
describe("SessionTodo", () => {
|
||||
it.effect("replaces persisted todos in order and publishes updates", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
const events = yield* EventV2.Service
|
||||
const todos = yield* SessionTodo.Service
|
||||
const published = new Array<EventV2.Payload>()
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === SessionTodo.Event.Updated.type) published.push(event)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
yield* todos.update({
|
||||
sessionID,
|
||||
todos: [
|
||||
{ content: "second", status: "pending", priority: "low" },
|
||||
{ content: "first", status: "in_progress", priority: "high" },
|
||||
],
|
||||
})
|
||||
expect(yield* todos.get(sessionID)).toEqual([
|
||||
{ content: "second", status: "pending", priority: "low" },
|
||||
{ content: "first", status: "in_progress", priority: "high" },
|
||||
])
|
||||
expect(
|
||||
(yield* db.select().from(TodoTable).orderBy(asc(TodoTable.position)).all().pipe(Effect.orDie)).map((row) => ({
|
||||
content: row.content,
|
||||
position: row.position,
|
||||
})),
|
||||
).toEqual([
|
||||
{ content: "second", position: 0 },
|
||||
{ content: "first", position: 1 },
|
||||
])
|
||||
|
||||
yield* todos.update({ sessionID, todos: [{ content: "replacement", status: "completed", priority: "medium" }] })
|
||||
expect(yield* todos.get(sessionID)).toEqual([{ content: "replacement", status: "completed", priority: "medium" }])
|
||||
|
||||
yield* todos.update({ sessionID, todos: [] })
|
||||
expect(yield* todos.get(sessionID)).toEqual([])
|
||||
expect(published.map((event) => event.data)).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
todos: [
|
||||
{ content: "second", status: "pending", priority: "low" },
|
||||
{ content: "first", status: "in_progress", priority: "high" },
|
||||
],
|
||||
},
|
||||
{ sessionID, todos: [{ content: "replacement", status: "completed", priority: "medium" }] },
|
||||
{ sessionID, todos: [] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
159
packages/core/test/session-tool-progress.test.ts
Normal file
159
packages/core/test/session-tool-progress.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
|
||||
const it = testEffect(Layer.mergeAll(database, events, projector))
|
||||
const timestamp = DateTime.makeUnsafe(1)
|
||||
const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
|
||||
|
||||
const content = (text: string) => [ToolOutput.text({ type: "text", text })]
|
||||
|
||||
describe("Tool.Progress", () => {
|
||||
it.effect("projects durable progress and keeps final settlements durable", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const service = yield* EventV2.Service
|
||||
const sessionID = SessionV2.ID.make("ses_tool_progress_projector")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "progress",
|
||||
directory: "/project",
|
||||
title: "progress",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const assistantMessageID = (yield* service.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
timestamp,
|
||||
agent: "build",
|
||||
model,
|
||||
})).id
|
||||
const readAssistant = Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, assistantMessageID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* Effect.die("Missing projected assistant")
|
||||
return Schema.decodeUnknownSync(SessionMessage.Assistant)({ ...row.data, id: row.id, type: row.type })
|
||||
})
|
||||
const start = (callID: string) =>
|
||||
Effect.gen(function* () {
|
||||
yield* service.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
timestamp,
|
||||
assistantMessageID,
|
||||
callID,
|
||||
name: "bash",
|
||||
})
|
||||
yield* service.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
timestamp,
|
||||
assistantMessageID,
|
||||
callID,
|
||||
tool: "bash",
|
||||
input: { command: "pwd" },
|
||||
provider: { executed: false },
|
||||
})
|
||||
})
|
||||
|
||||
yield* start("call-success")
|
||||
expect((yield* readAssistant).content[0]).toMatchObject({
|
||||
state: { status: "running", structured: {}, content: [] },
|
||||
})
|
||||
|
||||
yield* service.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID,
|
||||
timestamp,
|
||||
assistantMessageID,
|
||||
callID: "call-success",
|
||||
structured: { phase: "checkpoint" },
|
||||
content: content("saved"),
|
||||
})
|
||||
expect((yield* readAssistant).content[0]).toMatchObject({
|
||||
state: { status: "running", structured: { phase: "checkpoint" }, content: content("saved") },
|
||||
})
|
||||
|
||||
const success = yield* service.publish(SessionEvent.Tool.Success, {
|
||||
sessionID,
|
||||
timestamp,
|
||||
assistantMessageID,
|
||||
callID: "call-success",
|
||||
structured: { phase: "done" },
|
||||
content: content("complete"),
|
||||
provider: { executed: false },
|
||||
})
|
||||
expect((yield* readAssistant).content[0]).toMatchObject({
|
||||
state: { status: "completed", structured: { phase: "done" }, content: content("complete") },
|
||||
})
|
||||
|
||||
yield* start("call-failed")
|
||||
yield* service.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID,
|
||||
timestamp,
|
||||
assistantMessageID,
|
||||
callID: "call-failed",
|
||||
structured: { phase: "checkpoint" },
|
||||
content: content("before failure"),
|
||||
})
|
||||
const failed = yield* service.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID,
|
||||
timestamp,
|
||||
assistantMessageID,
|
||||
callID: "call-failed",
|
||||
error: { type: "unknown", message: "boom" },
|
||||
provider: { executed: false },
|
||||
})
|
||||
expect((yield* readAssistant).content[1]).toMatchObject({
|
||||
state: {
|
||||
status: "error",
|
||||
structured: { phase: "checkpoint" },
|
||||
content: content("before failure"),
|
||||
error: { type: "unknown", message: "boom" },
|
||||
},
|
||||
})
|
||||
expect(Schema.is(SessionEvent.Durable)(success)).toBe(true)
|
||||
expect(Schema.is(SessionEvent.Durable)(failed)).toBe(true)
|
||||
|
||||
const rows = yield* db
|
||||
.select({ type: EventTable.type })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Progress.type, 1))
|
||||
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Success.type, 1))
|
||||
expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 1))
|
||||
}),
|
||||
)
|
||||
})
|
||||
104
packages/core/test/skill-discovery.test.ts
Normal file
104
packages/core/test/skill-discovery.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const base = "https://skills.example.test/catalog/"
|
||||
|
||||
async function pull(skills: unknown[], files: Record<string, string> = {}) {
|
||||
const tmp = await tmpdir()
|
||||
const requests: string[] = []
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.sync(() => requests.push(request.url)).pipe(
|
||||
Effect.map(() => {
|
||||
const body = request.url === `${base}index.json` ? JSON.stringify({ skills }) : files[request.url]
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(body ?? "Not Found", { status: body === undefined ? 404 : 200 }),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const layer = SkillDiscovery.layer.pipe(
|
||||
Layer.provide(http),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Global.layerWith({ cache: tmp.path })),
|
||||
)
|
||||
const directories = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* (yield* SkillDiscovery.Service).pull(base)
|
||||
}).pipe(Effect.provide(layer)),
|
||||
)
|
||||
return { tmp, requests, directories }
|
||||
}
|
||||
|
||||
describe("SkillDiscovery.pull", () => {
|
||||
test("rejects skill name traversal without fetching files", async () => {
|
||||
const result = await pull([{ name: "../outside", files: ["SKILL.md"] }])
|
||||
try {
|
||||
expect(result.directories).toEqual([])
|
||||
expect(result.requests).toEqual([`${base}index.json`])
|
||||
expect(await fs.readdir(result.tmp.path)).toEqual([])
|
||||
} finally {
|
||||
await result.tmp[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects file traversal without fetching files", async () => {
|
||||
const result = await pull([{ name: "deploy", files: ["SKILL.md", "../outside.md"] }])
|
||||
try {
|
||||
expect(result.directories).toEqual([])
|
||||
expect(result.requests).toEqual([`${base}index.json`])
|
||||
expect(await fs.readdir(result.tmp.path)).toEqual([])
|
||||
} finally {
|
||||
await result.tmp[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects absolute file paths without fetching files", async () => {
|
||||
const result = await pull([{ name: "deploy", files: ["SKILL.md", "/tmp/outside.md"] }])
|
||||
try {
|
||||
expect(result.directories).toEqual([])
|
||||
expect(result.requests).toEqual([`${base}index.json`])
|
||||
expect(await fs.readdir(result.tmp.path)).toEqual([])
|
||||
} finally {
|
||||
await result.tmp[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects cross-origin file URLs without fetching files", async () => {
|
||||
const result = await pull([{ name: "deploy", files: ["SKILL.md", "https://evil.example.test/outside.md"] }])
|
||||
try {
|
||||
expect(result.directories).toEqual([])
|
||||
expect(result.requests).toEqual([`${base}index.json`])
|
||||
expect(await fs.readdir(result.tmp.path)).toEqual([])
|
||||
} finally {
|
||||
await result.tmp[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
|
||||
test("downloads safe nested files under the skill root", async () => {
|
||||
const result = await pull([{ name: "deploy", files: ["SKILL.md", "references/guide.md"] }], {
|
||||
[`${base}deploy/SKILL.md`]: "# Deploy",
|
||||
[`${base}deploy/references/guide.md`]: "# Guide",
|
||||
})
|
||||
try {
|
||||
expect(result.directories).toHaveLength(1)
|
||||
expect(result.requests.toSorted()).toEqual(
|
||||
[`${base}index.json`, `${base}deploy/SKILL.md`, `${base}deploy/references/guide.md`].toSorted(),
|
||||
)
|
||||
expect(await fs.readFile(path.join(result.directories[0], "SKILL.md"), "utf8")).toBe("# Deploy")
|
||||
expect(await fs.readFile(path.join(result.directories[0], "references", "guide.md"), "utf8")).toBe("# Guide")
|
||||
} finally {
|
||||
await result.tmp[Symbol.asyncDispose]()
|
||||
}
|
||||
})
|
||||
})
|
||||
130
packages/core/test/skill.test.ts
Normal file
130
packages/core/test/skill.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const urls = new Map<string, AbsolutePath[]>()
|
||||
let pulls = 0
|
||||
const discovery = Layer.succeed(
|
||||
SkillDiscovery.Service,
|
||||
SkillDiscovery.Service.of({
|
||||
pull: (url) => {
|
||||
pulls++
|
||||
return Effect.succeed(urls.get(url) ?? [])
|
||||
},
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
SkillV2.layer.pipe(
|
||||
Layer.provide(discovery),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provideMerge(AgentV2.locationLayer),
|
||||
),
|
||||
)
|
||||
|
||||
function write(directory: string, name: string, description: string) {
|
||||
return fs.writeFile(
|
||||
path.join(directory, name, "SKILL.md"),
|
||||
`---
|
||||
name: ${name}
|
||||
description: ${description}
|
||||
---
|
||||
# ${name}`,
|
||||
)
|
||||
}
|
||||
|
||||
describe("SkillV2", () => {
|
||||
it.live("registers sources and resolves later source precedence", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const first = path.join(tmp.path, "first")
|
||||
const second = path.join(tmp.path, "second")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(first, "review"), { recursive: true })
|
||||
await fs.mkdir(path.join(second, "review"), { recursive: true })
|
||||
await write(first, "review", "First")
|
||||
await write(second, "review", "Second")
|
||||
await fs.writeFile(path.join(first, "foo.md"), "---\nslash: true\n---\n# foo")
|
||||
})
|
||||
|
||||
const skill = yield* SkillV2.Service
|
||||
const register = yield* skill.transform()
|
||||
yield* register((editor) => {
|
||||
editor.source({ type: "directory", path: AbsolutePath.make(first) })
|
||||
editor.source({ type: "directory", path: AbsolutePath.make(first) })
|
||||
editor.source({ type: "directory", path: AbsolutePath.make(second) })
|
||||
expect(editor.list()).toEqual([
|
||||
{ type: "directory", path: AbsolutePath.make(first) },
|
||||
{ type: "directory", path: AbsolutePath.make(second) },
|
||||
])
|
||||
})
|
||||
|
||||
expect(yield* skill.sources()).toEqual([
|
||||
{ type: "directory", path: AbsolutePath.make(first) },
|
||||
{ type: "directory", path: AbsolutePath.make(second) },
|
||||
])
|
||||
expect(yield* skill.list()).toEqual([
|
||||
new SkillV2.Info({
|
||||
name: "foo",
|
||||
slash: true,
|
||||
location: AbsolutePath.make(path.join(first, "foo.md")),
|
||||
content: "# foo",
|
||||
}),
|
||||
{
|
||||
name: "review",
|
||||
description: "Second",
|
||||
location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
|
||||
content: "# review",
|
||||
},
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads URL sources and filters skills for agents", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
|
||||
await write(tmp.path, "deploy", "Deploy production")
|
||||
})
|
||||
pulls = 0
|
||||
urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)])
|
||||
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.update((editor) =>
|
||||
editor.update(AgentV2.ID.make("reviewer"), (agent) => {
|
||||
agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
const skill = yield* SkillV2.Service
|
||||
const register = yield* skill.transform()
|
||||
yield* register((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))
|
||||
|
||||
expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"])
|
||||
expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"])
|
||||
expect(pulls).toBe(1)
|
||||
expect(yield* skill.forAgent(AgentV2.ID.make("reviewer"))).toEqual([])
|
||||
expect(yield* skill.forAgent(AgentV2.ID.make("missing"))).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
188
packages/core/test/system-context.test.ts
Normal file
188
packages/core/test/system-context.test.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
|
||||
const key = SystemContext.Key.make
|
||||
|
||||
describe("SystemContext", () => {
|
||||
test("loads one coherent sample and initializes a deterministic baseline", async () => {
|
||||
let loads = 0
|
||||
const context = SystemContext.struct({
|
||||
date: SystemContext.value({
|
||||
key: key("core/date"),
|
||||
load: Effect.sync(() => {
|
||||
loads++
|
||||
return { baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }
|
||||
}),
|
||||
}),
|
||||
location: SystemContext.value({
|
||||
key: key("core/location"),
|
||||
load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }),
|
||||
}),
|
||||
})
|
||||
|
||||
const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context)))
|
||||
|
||||
expect(loads).toBe(1)
|
||||
expect(initialized).toEqual({
|
||||
baseline: [
|
||||
{ key: key("core/date"), text: "Today's date is 2026-06-03." },
|
||||
{ key: key("core/location"), text: "Working directory: /repo" },
|
||||
],
|
||||
checkpoint: {
|
||||
"core/date": Hash.sha256("The current date is 2026-06-03."),
|
||||
"core/location": Hash.sha256("The working directory is /repo."),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("emits changed and newly registered components in declaration order", async () => {
|
||||
const context = SystemContext.struct({
|
||||
date: SystemContext.value({
|
||||
key: key("core/date"),
|
||||
load: Effect.succeed({ baseline: "Today's date is 2026-06-04.", update: "The current date is 2026-06-04." }),
|
||||
}),
|
||||
location: SystemContext.value({
|
||||
key: key("core/location"),
|
||||
load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }),
|
||||
}),
|
||||
skills: SystemContext.value({
|
||||
key: key("core/skills"),
|
||||
load: Effect.succeed({ baseline: "Available skills: effect", update: "Available skills: effect" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), {
|
||||
"core/date": Hash.sha256("The current date is 2026-06-03."),
|
||||
"core/location": Hash.sha256("The working directory is /repo."),
|
||||
})
|
||||
|
||||
expect(refreshed).toEqual({
|
||||
changes: [
|
||||
{ key: key("core/date"), text: "The current date is 2026-06-04." },
|
||||
{ key: key("core/skills"), text: "Available skills: effect" },
|
||||
],
|
||||
checkpoint: {
|
||||
"core/date": Hash.sha256("The current date is 2026-06-04."),
|
||||
"core/location": Hash.sha256("The working directory is /repo."),
|
||||
"core/skills": Hash.sha256("Available skills: effect"),
|
||||
},
|
||||
})
|
||||
expect(SystemContext.render(refreshed.changes)).toBe("The current date is 2026-06-04.\n\nAvailable skills: effect")
|
||||
})
|
||||
|
||||
test("omits unavailable initial context and admits it after its first successful load", async () => {
|
||||
let available = false
|
||||
const context = SystemContext.struct({
|
||||
remote: SystemContext.value({
|
||||
key: key("core/remote-instructions"),
|
||||
load: Effect.sync(() =>
|
||||
available
|
||||
? { baseline: "Remote instructions: available", update: "Remote instructions are now available." }
|
||||
: SystemContext.unavailable,
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context)))
|
||||
available = true
|
||||
const refreshed = SystemContext.refresh(
|
||||
await Effect.runPromise(SystemContext.load(context)),
|
||||
initialized.checkpoint,
|
||||
)
|
||||
|
||||
expect(initialized).toEqual({ baseline: [], checkpoint: {} })
|
||||
expect(refreshed.changes).toEqual([
|
||||
{ key: key("core/remote-instructions"), text: "Remote instructions are now available." },
|
||||
])
|
||||
})
|
||||
|
||||
test("retains an existing checkpoint while context is unavailable", async () => {
|
||||
const previous = { "core/remote-instructions": Hash.sha256("Remote instructions: old") }
|
||||
const context = SystemContext.struct({
|
||||
remote: SystemContext.value({
|
||||
key: key("core/remote-instructions"),
|
||||
load: Effect.succeed(SystemContext.unavailable),
|
||||
}),
|
||||
})
|
||||
|
||||
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous)
|
||||
|
||||
expect(refreshed).toEqual({ changes: [], checkpoint: previous })
|
||||
})
|
||||
|
||||
test("drops checkpoints for removed components", async () => {
|
||||
const context = SystemContext.struct({
|
||||
date: SystemContext.value({
|
||||
key: key("core/date"),
|
||||
load: Effect.succeed({ baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }),
|
||||
}),
|
||||
})
|
||||
|
||||
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), {
|
||||
"core/date": Hash.sha256("The current date is 2026-06-03."),
|
||||
"plugin/removed": Hash.sha256("Removed plugin context"),
|
||||
})
|
||||
|
||||
expect(refreshed).toEqual({
|
||||
changes: [],
|
||||
checkpoint: { "core/date": Hash.sha256("The current date is 2026-06-03.") },
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores inherited checkpoint properties", async () => {
|
||||
const context = SystemContext.struct({
|
||||
date: SystemContext.value({
|
||||
key: key("core/date"),
|
||||
load: Effect.succeed({ baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }),
|
||||
}),
|
||||
})
|
||||
const previous = Object.create({
|
||||
"core/date": Hash.sha256("The current date is 2026-06-03."),
|
||||
}) as SystemContext.Checkpoint
|
||||
|
||||
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous)
|
||||
|
||||
expect(refreshed.changes).toEqual([{ key: key("core/date"), text: "The current date is 2026-06-03." }])
|
||||
expect(Object.hasOwn(refreshed.checkpoint, "core/date")).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves unexpected loader failures", async () => {
|
||||
const context = SystemContext.struct({
|
||||
broken: SystemContext.value({
|
||||
key: key("plugin/broken"),
|
||||
load: Effect.fail("broken loader"),
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(Effect.runPromise(SystemContext.load(context))).rejects.toBe("broken loader")
|
||||
})
|
||||
|
||||
test("rejects duplicate component keys", () => {
|
||||
expect(() =>
|
||||
SystemContext.struct({
|
||||
one: SystemContext.value({ key: key("core/date"), load: Effect.succeed({ baseline: "one", update: "one" }) }),
|
||||
two: SystemContext.value({ key: key("core/date"), load: Effect.succeed({ baseline: "two", update: "two" }) }),
|
||||
}),
|
||||
).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") }))
|
||||
})
|
||||
|
||||
test("rejects duplicate component keys at the interpreter boundary", async () => {
|
||||
const component = SystemContext.value({
|
||||
key: key("core/date"),
|
||||
load: Effect.succeed({ baseline: "date", update: "date" }),
|
||||
})
|
||||
const context: SystemContext.SystemContext = { components: [component, component] }
|
||||
|
||||
await expect(Effect.runPromise(SystemContext.load(context))).rejects.toBeInstanceOf(SystemContext.DuplicateKeyError)
|
||||
})
|
||||
|
||||
test("requires namespaced component keys", () => {
|
||||
const decode = Schema.decodeUnknownSync(SystemContext.Key)
|
||||
|
||||
expect(decode("core/date")).toBe(key("core/date"))
|
||||
expect(() => decode("date")).toThrow()
|
||||
expect(() => decode("core/")).toThrow()
|
||||
})
|
||||
})
|
||||
368
packages/core/test/tool-apply-patch.test.ts
Normal file
368
packages/core/test/tool-apply-patch.test.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { FSUtil } from "@opencode-ai/core/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"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
|
||||
import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
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
|
||||
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
if (input.action === "edit") editApproved = true
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
denyAction = undefined
|
||||
failRemoveTarget = undefined
|
||||
readsBeforeEditApproval = 0
|
||||
editApproved = false
|
||||
blockRemoveTarget = undefined
|
||||
removeStarted = undefined
|
||||
releaseRemove = undefined
|
||||
}
|
||||
|
||||
const filesystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...fs,
|
||||
readFile: (target) =>
|
||||
Effect.sync(() => {
|
||||
if (!editApproved) readsBeforeEditApproval++
|
||||
}).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(FSUtil.defaultLayer))
|
||||
|
||||
const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
|
||||
const activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
|
||||
const patch = ApplyPatchTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(planning),
|
||||
Layer.provide(commits),
|
||||
Layer.provide(filesystem),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(Effect.provide(Layer.mergeAll(registry, planning, commits, patch)))
|
||||
}
|
||||
|
||||
const call = (patchText: string, id = "call-apply-patch") => ({
|
||||
sessionID,
|
||||
call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } },
|
||||
})
|
||||
|
||||
const exists = (target: string) =>
|
||||
Effect.promise(() =>
|
||||
fs.stat(target).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
)
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("ApplyPatchTool", () => {
|
||||
it.live("registers and sequentially applies add, update, and delete hunks", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const update = path.join(tmp.path, "update.txt")
|
||||
const remove = path.join(tmp.path, "remove.txt")
|
||||
return Effect.promise(() =>
|
||||
Promise.all([fs.writeFile(update, "before\n"), fs.writeFile(remove, "remove\n")]),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["apply_patch"])
|
||||
const settled = yield* registry.settle(
|
||||
call(
|
||||
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
|
||||
),
|
||||
)
|
||||
expect(settled.result).toEqual({
|
||||
type: "text",
|
||||
value: "Applied patch sequentially:\nA nested/new.txt\nM update.txt\nD remove.txt",
|
||||
})
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
applied: [
|
||||
{ type: "add", resource: "nested/new.txt" },
|
||||
{ type: "update", resource: "update.txt" },
|
||||
{ type: "delete", resource: "remove.txt" },
|
||||
],
|
||||
})
|
||||
expect(assertions).toEqual([
|
||||
{ sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] },
|
||||
])
|
||||
expect(readsBeforeEditApproval).toBe(0)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "nested/new.txt"), "utf8"))).toBe(
|
||||
"created\n",
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.readFile(update, "utf8"))).toBe("after\n")
|
||||
expect(yield* exists(remove)).toBe(false)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects moves before applying any hunk", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const source = path.join(tmp.path, "old.txt")
|
||||
return Effect.promise(() => fs.writeFile(source, "before\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(
|
||||
call(
|
||||
"*** 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: "apply_patch moves are not supported yet" })
|
||||
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
|
||||
expect(assertions).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("approves an external directory and the batch before reading external update content", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
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* registry.execute(
|
||||
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(readsBeforeEditApproval).toBe(0)
|
||||
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 one external directory scope for multiple files under the same parent", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
const first = path.join(outside.path, "first.txt")
|
||||
const second = path.join(outside.path, "second.txt")
|
||||
return Effect.promise(() =>
|
||||
Promise.all([fs.writeFile(first, "before\n"), fs.writeFile(second, "before\n")]),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
withTool(active.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(
|
||||
call(
|
||||
`*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
|
||||
),
|
||||
),
|
||||
).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("\\", "/"),
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects invalid later update before applying an earlier add", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(
|
||||
call(
|
||||
"*** 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" })
|
||||
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects add hunks targeting an existing file without replacing it", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "existing.txt")
|
||||
return Effect.promise(() => fs.writeFile(target, "sentinel\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(
|
||||
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")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports earlier sequential applications when a later commit fails", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const first = path.join(tmp.path, "first.txt")
|
||||
const second = path.join(tmp.path, "second.txt")
|
||||
failRemoveTarget = path.basename(second)
|
||||
return Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")])).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(
|
||||
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: "Patch partially applied before failing at second.txt. Applied: first.txt",
|
||||
})
|
||||
expect(yield* exists(first)).toBe(false)
|
||||
expect(yield* exists(second)).toBe(true)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
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* registry
|
||||
.execute(
|
||||
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]()),
|
||||
),
|
||||
)
|
||||
})
|
||||
406
packages/core/test/tool-bash.test.ts
Normal file
406
packages/core/test/tool-bash.test.ts
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
import fs from "fs/promises"
|
||||
import { realpathSync } from "node:fs"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { BashTool } from "@opencode-ai/core/tool/bash"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_bash_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const runs: Array<{
|
||||
readonly command: string
|
||||
readonly cwd?: string
|
||||
readonly shell?: string | boolean
|
||||
readonly options?: AppProcess.RunOptions
|
||||
}> = []
|
||||
const truncations: ToolOutputStore.TruncateInput[] = []
|
||||
let denyAction: string | undefined
|
||||
let result: AppProcess.RunResult = {
|
||||
command: "mock",
|
||||
exitCode: 0,
|
||||
stdout: Buffer.from("hello\n"),
|
||||
stderr: Buffer.alloc(0),
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
}
|
||||
let runFailure: AppProcess.AppProcessError | undefined
|
||||
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
|
||||
Effect.succeed({ content: input.content, truncated: false })
|
||||
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const appProcess = Layer.succeed(
|
||||
AppProcess.Service,
|
||||
AppProcess.Service.of({
|
||||
run: (command: ChildProcess.Command, options?: AppProcess.RunOptions) =>
|
||||
Effect.suspend(() => {
|
||||
if (command._tag !== "StandardCommand") throw new Error("expected standard command")
|
||||
runs.push({ command: command.command, cwd: command.options.cwd, shell: command.options.shell, options })
|
||||
return runFailure ? Effect.fail(runFailure) : Effect.succeed(result)
|
||||
}),
|
||||
} as unknown as AppProcess.Interface),
|
||||
)
|
||||
const resources = Layer.succeed(
|
||||
ToolOutputStore.Service,
|
||||
ToolOutputStore.Service.of({
|
||||
limits: () => Effect.die("unused"),
|
||||
write: () => Effect.die("unused"),
|
||||
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
|
||||
read: () => Effect.die("unused"),
|
||||
cleanup: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const config = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
runs.length = 0
|
||||
truncations.length = 0
|
||||
denyAction = undefined
|
||||
runFailure = undefined
|
||||
result = {
|
||||
command: "mock",
|
||||
exitCode: 0,
|
||||
stdout: Buffer.from("hello\n"),
|
||||
stderr: Buffer.alloc(0),
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
}
|
||||
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
|
||||
}
|
||||
|
||||
const withTool = <A, E, R>(
|
||||
directory: string,
|
||||
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
|
||||
processLayer: Layer.Layer<AppProcess.Service> = appProcess,
|
||||
) => {
|
||||
const filesystem = FSUtil.defaultLayer
|
||||
const activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
|
||||
const bash = BashTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(processLayer),
|
||||
Layer.provide(resources),
|
||||
Layer.provide(config),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(Effect.provide(Layer.mergeAll(registry, bash)))
|
||||
}
|
||||
|
||||
const call = (input: typeof BashTool.Parameters.Type, id = "call-bash") => ({
|
||||
sessionID,
|
||||
call: { type: "tool-call" as const, id, name: "bash", input },
|
||||
})
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("BashTool", () => {
|
||||
it.live("registers and returns structured successful output from the active Location", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const definitions = yield* registry.definitions()
|
||||
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
|
||||
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
|
||||
expect(yield* registry.settle(call({ command: "pwd", description: "Print working directory" }))).toEqual({
|
||||
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
|
||||
output: {
|
||||
structured: {
|
||||
command: "pwd",
|
||||
cwd: realpathSync(tmp.path),
|
||||
exitCode: 0,
|
||||
output: "hello\n",
|
||||
truncated: false,
|
||||
},
|
||||
content: [{ type: "text", text: "hello\n\n\nCommand exited with code 0." }],
|
||||
},
|
||||
})
|
||||
expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }])
|
||||
expect(runs[0]?.options).toMatchObject({
|
||||
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
|
||||
maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
|
||||
})
|
||||
expect(assertions).toEqual([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("resolves a relative workdir from the active Location", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
|
||||
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))),
|
||||
Effect.andThen(
|
||||
Effect.sync(() => expect(runs).toMatchObject([{ cwd: realpathSync(path.join(tmp.path, "src")) }])),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
it.live("executes a real shell command through AppProcess", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withTool(
|
||||
tmp.path,
|
||||
(registry) => registry.settle(call({ command: "printf core-bash" })),
|
||||
AppProcess.defaultLayer,
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.result).toEqual({ type: "text", value: "core-bash\n\nCommand exited with code 0." })
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
command: "printf core-bash",
|
||||
cwd: realpathSync(tmp.path),
|
||||
exitCode: 0,
|
||||
output: "core-bash",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("approves an explicit external workdir before bash execution", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
return withTool(active.path, (registry) =>
|
||||
registry.execute(call({ command: "pwd", workdir: outside.path })),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "bash"])
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
|
||||
})
|
||||
expect(runs).toHaveLength(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not execute after external-directory or bash denial", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
denyAction = "external_directory"
|
||||
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd", workdir: outside.path })))
|
||||
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
|
||||
expect(runs).toEqual([])
|
||||
|
||||
reset()
|
||||
denyAction = "bash"
|
||||
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd" })))
|
||||
expect(assertions.map((item) => item.action)).toEqual(["bash"])
|
||||
expect(runs).toEqual([])
|
||||
}),
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports external command arguments as advisory warnings without enforcing approval", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
denyAction = "external_directory"
|
||||
const target = path.join(outside.path, "secret.txt")
|
||||
return withTool(active.path, (registry) => registry.settle(call({ command: `cat ${target}` }))).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(assertions.map((item) => item.action)).toEqual(["bash"])
|
||||
expect(runs).toHaveLength(1)
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
warnings: [
|
||||
`Command argument references external directory ${path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
|
||||
],
|
||||
})
|
||||
expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("Warnings:") })
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps non-zero exits useful and exposes managed overflow by opaque URI", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
|
||||
truncate = (input) =>
|
||||
Effect.succeed({
|
||||
content: "HEAD\n\n... output truncated; full content available as tool-output://opaque ...\n\nTAIL",
|
||||
truncated: true,
|
||||
resource: new ToolOutputStore.Resource({
|
||||
uri: "tool-output://opaque",
|
||||
mime: "text/plain",
|
||||
size: input.content.length,
|
||||
}),
|
||||
})
|
||||
return withTool(tmp.path, (registry) => registry.settle(call({ command: "false" }, "call-overflow"))).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.result).toMatchObject({
|
||||
type: "text",
|
||||
value: expect.stringContaining("Command exited with code 7"),
|
||||
})
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
command: "false",
|
||||
cwd: realpathSync(tmp.path),
|
||||
exitCode: 7,
|
||||
truncated: true,
|
||||
resource: { uri: "tool-output://opaque" },
|
||||
})
|
||||
expect(truncations).toMatchObject([
|
||||
{ sessionID, toolCallID: "call-overflow", content: "HEAD full output TAIL" },
|
||||
])
|
||||
expect(JSON.stringify(settled)).not.toContain(tmp.path + path.sep + "tool-output")
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("surfaces bounded process-capture truncation", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
result = { ...result, stdoutTruncated: true }
|
||||
return withTool(tmp.path, (registry) => registry.settle(call({ command: "verbose" }))).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true })
|
||||
expect(settled.result).toMatchObject({
|
||||
type: "text",
|
||||
value: expect.stringContaining("stdout capture truncated"),
|
||||
})
|
||||
expect(settled.output?.structured).not.toHaveProperty("resource")
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns a useful timeout settlement", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
runFailure = new AppProcess.AppProcessError({ command: "sleep", cause: new Error("Timed out") })
|
||||
return withTool(tmp.path, (registry) => registry.settle(call({ command: "sleep 60", timeout: 10 }))).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.result).toMatchObject({
|
||||
type: "text",
|
||||
value: expect.stringContaining("Command timed out"),
|
||||
})
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
command: "sleep 60",
|
||||
timedOut: true,
|
||||
truncated: false,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps locked deferred parity TODOs visible", async () => {
|
||||
const source = await fs.readFile(new URL("../src/tool/bash.ts", import.meta.url), "utf8")
|
||||
for (const todo of [
|
||||
"Port tree-sitter bash / PowerShell parser-based approval reduction.",
|
||||
"Port BashArity reusable command-prefix approvals.",
|
||||
"Replace token-based command-argument external-directory advisories with parser-based detection.",
|
||||
"Restore PowerShell and cmd-specific invocation/path handling on Windows.",
|
||||
"Add plugin shell.env environment augmentation once V2 plugin hooks exist.",
|
||||
"Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.",
|
||||
"Persist background job status and define restart recovery before exposing remote observation.",
|
||||
"Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.",
|
||||
"Revisit binary output handling if stdout/stderr decoding is text-only.",
|
||||
"Stream full shell output into managed storage while retaining only a bounded in-memory preview.",
|
||||
]) {
|
||||
expect(source).toContain(`TODO: ${todo}`)
|
||||
}
|
||||
})
|
||||
458
packages/core/test/tool-edit.test.ts
Normal file
458
packages/core/test/tool-edit.test.ts
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { FSUtil } from "@opencode-ai/core/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"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
|
||||
import { EditTool } from "@opencode-ai/core/tool/edit"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_edit_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const writes: string[] = []
|
||||
let reads = 0
|
||||
let denyAction: string | undefined
|
||||
let afterAssertion = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
|
||||
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
|
||||
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
input.action === denyAction
|
||||
? Effect.fail(new PermissionV2.DeniedError({ rules: [] }))
|
||||
: afterAssertion(input),
|
||||
),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
writes.length = 0
|
||||
reads = 0
|
||||
denyAction = undefined
|
||||
afterAssertion = () => Effect.void
|
||||
afterRead = () => Effect.void
|
||||
}
|
||||
|
||||
const filesystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
return FSUtil.Service.of({
|
||||
...fs,
|
||||
readFile: (target) =>
|
||||
fs
|
||||
.readFile(target)
|
||||
.pipe(
|
||||
Effect.tap((content) =>
|
||||
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, content)))),
|
||||
),
|
||||
),
|
||||
writeWithDirs: (target, content, mode) =>
|
||||
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
|
||||
const activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
const planning = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const commits = FileMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(planning))
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
|
||||
const edit = EditTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(planning),
|
||||
Layer.provide(commits),
|
||||
Layer.provide(filesystem),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(Effect.provide(Layer.mergeAll(registry, planning, commits, edit)))
|
||||
}
|
||||
|
||||
const call = (input: typeof EditTool.Parameters.Type, id = "call-edit") => ({
|
||||
sessionID,
|
||||
call: { type: "tool-call" as const, id, name: "edit", input },
|
||||
})
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("EditTool", () => {
|
||||
it.live("registers and replaces relative exact text through FileMutation once", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "hello.txt")
|
||||
return Effect.promise(() => fs.writeFile(target, "before\nrest\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["edit"])
|
||||
const settled = yield* registry.settle(
|
||||
call({ path: "hello.txt", oldString: "before", newString: "after" }),
|
||||
)
|
||||
expect(settled.result).toEqual({
|
||||
type: "text",
|
||||
value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```",
|
||||
})
|
||||
expect(settled.output?.structured).toEqual({
|
||||
operation: "write",
|
||||
target: yield* Effect.promise(() => fs.realpath(target)),
|
||||
resource: "hello.txt",
|
||||
existed: true,
|
||||
replacements: 1,
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
|
||||
expect(assertions).toEqual([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
|
||||
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("accepts an absolute file path inside the active Location", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "absolute.txt")
|
||||
return Effect.promise(() => fs.writeFile(target, "before")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
registry.execute(call({ path: target, oldString: "before", newString: "after" })),
|
||||
),
|
||||
),
|
||||
Effect.andThen((result) =>
|
||||
Effect.gen(function* () {
|
||||
expect(result.type).toBe("text")
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after")
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("approves an explicit external absolute path before edit", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
const target = path.join(outside.path, "external.txt")
|
||||
return Effect.promise(() => fs.writeFile(target, "before")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(active.path, (registry) =>
|
||||
registry.execute(call({ path: target, oldString: "before", newString: "after" })),
|
||||
),
|
||||
),
|
||||
Effect.andThen((result) =>
|
||||
Effect.gen(function* () {
|
||||
expect(result.type).toBe("text")
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after")
|
||||
expect(writes).toHaveLength(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not write when external_directory or edit approval is denied", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) =>
|
||||
Effect.gen(function* () {
|
||||
const external = path.join(outside.path, "denied.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(external, "before"))
|
||||
reset()
|
||||
denyAction = "external_directory"
|
||||
expect(
|
||||
yield* withTool(active.path, (registry) =>
|
||||
registry.execute(call({ path: external, oldString: "before", newString: "after" })),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: `Unable to edit ${external}`,
|
||||
})
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
|
||||
expect(reads).toBe(0)
|
||||
expect(writes).toEqual([])
|
||||
|
||||
reset()
|
||||
denyAction = "edit"
|
||||
expect(
|
||||
yield* withTool(active.path, (registry) =>
|
||||
registry.execute(call({ path: external, oldString: "before", newString: "after" })),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: `Unable to edit ${external}`,
|
||||
})
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(reads).toBe(0)
|
||||
expect(writes).toEqual([])
|
||||
expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before")
|
||||
}),
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("denied edit reads no target content and does not disclose whether oldString matches", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
denyAction = "edit"
|
||||
const target = path.join(tmp.path, "secret.txt")
|
||||
return Effect.promise(() => fs.writeFile(target, "secret content")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const matching = yield* registry.execute(
|
||||
call({ path: "secret.txt", oldString: "secret content", newString: "replacement" }),
|
||||
)
|
||||
const missing = yield* registry.execute(
|
||||
call({ path: "secret.txt", oldString: "not present", newString: "replacement" }),
|
||||
)
|
||||
|
||||
expect(matching).toEqual({ type: "error", value: "Unable to edit secret.txt" })
|
||||
expect(missing).toEqual(matching)
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
|
||||
expect(reads).toBe(0)
|
||||
expect(writes).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects no-op, empty, missing, and ambiguous exact replacements", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "matches.txt")
|
||||
return Effect.promise(() => fs.writeFile(target, "same same")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "same" })),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: "No changes to apply: oldString and newString are identical.",
|
||||
})
|
||||
expect(
|
||||
yield* registry.execute(call({ path: "matches.txt", oldString: "", newString: "after" })),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: "oldString must not be empty. Use write to create or overwrite a file.",
|
||||
})
|
||||
expect(
|
||||
yield* registry.execute(call({ path: "matches.txt", oldString: "missing", newString: "after" })),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value:
|
||||
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
|
||||
})
|
||||
expect(
|
||||
yield* registry.execute(call({ path: "matches.txt", oldString: "same", newString: "after" })),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value:
|
||||
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
|
||||
})
|
||||
expect(writes).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("replaces every exact occurrence when replaceAll is true", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "all.txt")
|
||||
return Effect.promise(() => fs.writeFile(target, "same same same")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
registry.settle(call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
|
||||
),
|
||||
),
|
||||
Effect.andThen((settled) =>
|
||||
Effect.gen(function* () {
|
||||
expect(settled.output?.structured).toMatchObject({ replacements: 3 })
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after after after")
|
||||
expect(writes).toHaveLength(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves BOM and CRLF line endings", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "windows.txt")
|
||||
return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
registry.execute(call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })),
|
||||
),
|
||||
),
|
||||
Effect.andThen(() => Effect.promise(() => fs.readFile(target, "utf8"))),
|
||||
Effect.tap((content) => Effect.sync(() => expect(content).toBe("\uFEFFafter\r\nrest\r\n"))),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects an in-place content change after matching but before conditional commit", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "concurrent.txt")
|
||||
afterRead = () => (reads === 1 ? Effect.promise(() => fs.writeFile(target, "newer\n")) : Effect.void)
|
||||
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
registry.execute(call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
|
||||
),
|
||||
),
|
||||
Effect.andThen((result) =>
|
||||
Effect.gen(function* () {
|
||||
expect(result).toEqual({
|
||||
type: "error",
|
||||
value: "File changed after permission approval. Read it again before editing.",
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("newer\n")
|
||||
expect(writes).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
it.live("delegates post-approval revalidation to FileMutation before writing", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
const parent = path.join(active.path, "parent")
|
||||
const detached = path.join(active.path, "detached")
|
||||
afterAssertion = (input) =>
|
||||
input.action === "edit"
|
||||
? Effect.promise(async () => {
|
||||
await fs.rename(parent, detached)
|
||||
await fs.symlink(outside.path, parent)
|
||||
})
|
||||
: Effect.void
|
||||
return Effect.promise(async () => {
|
||||
await fs.mkdir(parent)
|
||||
await fs.writeFile(path.join(parent, "escape.txt"), "before")
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
withTool(active.path, (registry) =>
|
||||
registry.execute(call({ path: "parent/escape.txt", oldString: "before", newString: "after" })),
|
||||
),
|
||||
),
|
||||
Effect.andThen((result) =>
|
||||
Effect.gen(function* () {
|
||||
expect(result).toEqual({ type: "error", value: "Unable to edit parent/escape.txt" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(writes).toEqual([])
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
fs.stat(path.join(outside.path, "escape.txt")).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the locked edit schema, semantics docstring, and deferred TODOs visible", async () => {
|
||||
const source = (await fs.readFile(new URL("../src/tool/edit.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n")
|
||||
const definition = await Effect.runPromise(
|
||||
withTool(path.dirname(fileURLToPath(import.meta.url)), (registry) => registry.definitions()),
|
||||
)
|
||||
const schema = definition[0]?.inputSchema as { readonly properties?: Record<string, unknown> }
|
||||
|
||||
expect(Object.keys(schema.properties ?? {}).sort()).toEqual(["newString", "oldString", "path", "replaceAll"])
|
||||
expect(source).toContain(
|
||||
"Named project references\n * are read-oriented and deliberately are not accepted by mutation tools.",
|
||||
)
|
||||
for (const todo of [
|
||||
"Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.",
|
||||
"Add formatter integration after V2 formatter runtime exists.",
|
||||
"Publish watcher/file-edit events after V2 watcher integration exists.",
|
||||
"Add snapshots / undo after design exists.",
|
||||
"Add LSP notification and diagnostics after V2 LSP runtime exists.",
|
||||
]) {
|
||||
expect(source).toContain(`TODO: ${todo}`)
|
||||
}
|
||||
})
|
||||
231
packages/core/test/tool-glob.test.ts
Normal file
231
packages/core/test/tool-glob.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { LocationSearch } from "@opencode-ai/core/location-search"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { RelativePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { GlobTool } from "@opencode-ai/core/tool/glob"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_glob_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const resolutions: FileSystem.ListInput[] = []
|
||||
const searches: LocationSearch.FilesInput[] = []
|
||||
const roots: FileSystem.RootTarget[] = []
|
||||
let allow = true
|
||||
let result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
|
||||
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] }))),
|
||||
),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const filesystem = Layer.succeed(
|
||||
FileSystem.Service,
|
||||
FileSystem.Service.of({
|
||||
read: () => Effect.die("unused"),
|
||||
resolveReadPath: () => Effect.die("unused"),
|
||||
resolveRead: () => Effect.die("unused"),
|
||||
readResolved: () => Effect.die("unused"),
|
||||
readTextPageResolved: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
resolveRoot: (input = {}) =>
|
||||
Effect.sync(() => {
|
||||
resolutions.push(input)
|
||||
const relative = input.path ?? RelativePath.make(".")
|
||||
const resource = input.reference === undefined ? relative : `${input.reference}:${relative}`
|
||||
return new FileSystem.RootTarget({
|
||||
absolute: `/project/${relative}`,
|
||||
real: `/project/${relative}`,
|
||||
directory: "/project",
|
||||
root: "/project",
|
||||
resource,
|
||||
reference: input.reference,
|
||||
type: "directory",
|
||||
dev: 1,
|
||||
})
|
||||
}),
|
||||
revalidateRoot: Effect.succeed,
|
||||
resolveList: () => Effect.die("unused"),
|
||||
listResolved: () => Effect.die("unused"),
|
||||
listPage: () => Effect.die("unused"),
|
||||
listPageResolved: () => Effect.die("unused"),
|
||||
find: () => Effect.die("unused"),
|
||||
grep: () => Effect.die("unused"),
|
||||
isIgnored: () => false,
|
||||
}),
|
||||
)
|
||||
|
||||
const search = Layer.succeed(
|
||||
LocationSearch.Service,
|
||||
LocationSearch.Service.of({
|
||||
files: (input, root) =>
|
||||
Effect.sync(() => {
|
||||
searches.push(input)
|
||||
if (root) roots.push(root)
|
||||
return result
|
||||
}),
|
||||
grep: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
|
||||
const glob = GlobTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(filesystem),
|
||||
Layer.provide(search),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(registry, permission, filesystem, search, glob))
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
resolutions.length = 0
|
||||
searches.length = 0
|
||||
roots.length = 0
|
||||
allow = true
|
||||
result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
|
||||
}
|
||||
|
||||
const call = (input: typeof GlobTool.Parameters.Type, id = "call-glob") => ({
|
||||
sessionID,
|
||||
call: { type: "tool-call" as const, id, name: "glob", input },
|
||||
})
|
||||
|
||||
describe("GlobTool", () => {
|
||||
it.effect("registers the glob definition", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
expect((yield* (yield* ToolRegistry.Service).definitions()).map((tool) => tool.name)).toEqual(["glob"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("authorizes the active Location pattern and delegates traversal only to LocationSearch.files", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(yield* registry.execute(call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }))).toEqual({
|
||||
type: "text",
|
||||
value: "No files found",
|
||||
})
|
||||
expect(assertions).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
action: "glob",
|
||||
resources: ["**/*.ts"],
|
||||
save: ["*"],
|
||||
metadata: { root: "src", reference: undefined, path: "src", limit: 12 },
|
||||
},
|
||||
])
|
||||
expect(resolutions).toEqual([{ path: RelativePath.make("src"), reference: undefined }])
|
||||
expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
|
||||
expect(roots).toMatchObject([{ resource: "src" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prevents Location search when permission is denied", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
allow = false
|
||||
|
||||
expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.secret" }))).toEqual({
|
||||
type: "error",
|
||||
value: "Unable to find files matching *.secret",
|
||||
})
|
||||
expect(searches).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns active Location glob resources", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
result = new LocationSearch.FilesResult({
|
||||
items: [
|
||||
new LocationSearch.File({
|
||||
path: RelativePath.make("src/index.ts"),
|
||||
canonical: "/project/src/index.ts",
|
||||
resource: "src/index.ts",
|
||||
mtime: 1,
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
partial: false,
|
||||
})
|
||||
|
||||
expect(yield* (yield* ToolRegistry.Service).settle(call({ pattern: "*.ts" }))).toEqual({
|
||||
result: { type: "text", value: "src/index.ts" },
|
||||
output: {
|
||||
structured: result,
|
||||
content: [{ type: "text", text: "src/index.ts" }],
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("searches named references with root and reference metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
result = new LocationSearch.FilesResult({
|
||||
items: [
|
||||
new LocationSearch.File({
|
||||
path: RelativePath.make("guide.md"),
|
||||
canonical: "/project/docs/guide.md",
|
||||
resource: "docs:guide.md",
|
||||
mtime: 1,
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
partial: false,
|
||||
})
|
||||
|
||||
expect(yield* (yield* ToolRegistry.Service).execute(call({ pattern: "*.md", reference: "docs" }))).toEqual({
|
||||
type: "text",
|
||||
value: "docs:guide.md",
|
||||
})
|
||||
expect(assertions).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
action: "glob",
|
||||
resources: ["*.md"],
|
||||
save: ["*"],
|
||||
metadata: { root: "docs:.", reference: "docs", path: undefined, limit: undefined },
|
||||
},
|
||||
])
|
||||
expect(searches).toEqual([{ pattern: "*.md", reference: "docs" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("formats bounded and partial results without discarding structured output", () =>
|
||||
Effect.sync(() => {
|
||||
const output = new LocationSearch.FilesResult({
|
||||
items: [
|
||||
new LocationSearch.File({
|
||||
path: RelativePath.make("one.ts"),
|
||||
canonical: "/project/one.ts",
|
||||
resource: "one.ts",
|
||||
mtime: 1,
|
||||
}),
|
||||
],
|
||||
truncated: true,
|
||||
partial: true,
|
||||
})
|
||||
|
||||
expect(GlobTool.toModelOutput(output)).toBe(
|
||||
"one.ts\n\n(Results are truncated: showing first 1 results. Consider using a more specific path or pattern.)\n\n(Results may be incomplete because some discovered files could not be read.)",
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
286
packages/core/test/tool-grep.test.ts
Normal file
286
packages/core/test/tool-grep.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||
import { LocationSearch } from "@opencode-ai/core/location-search"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { ProjectReference } from "@opencode-ai/core/project-reference"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { GrepTool } from "@opencode-ai/core/tool/grep"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it as runtimeIt } from "./lib/effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const searches: LocationSearch.GrepInput[] = []
|
||||
const roots: FileSystem.RootTarget[] = []
|
||||
let allow = true
|
||||
let result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
|
||||
let searchFailure: Ripgrep.InvalidPatternError | undefined
|
||||
|
||||
const filesystem = Layer.succeed(
|
||||
FileSystem.Service,
|
||||
FileSystem.Service.of({
|
||||
read: () => Effect.die("unused"),
|
||||
resolveReadPath: () => Effect.die("unused"),
|
||||
resolveRead: () => Effect.die("unused"),
|
||||
readResolved: () => Effect.die("unused"),
|
||||
readTextPageResolved: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
resolveRoot: (input = {}) =>
|
||||
Effect.succeed(
|
||||
new FileSystem.RootTarget({
|
||||
absolute: `/project/${input.path ?? "."}`,
|
||||
real: `/project/${input.path ?? "."}`,
|
||||
directory: "/project",
|
||||
root: "/project",
|
||||
resource: input.reference === undefined ? (input.path ?? ".") : `${input.reference}:${input.path ?? "."}`,
|
||||
reference: input.reference,
|
||||
type: "directory",
|
||||
dev: 1,
|
||||
}),
|
||||
),
|
||||
revalidateRoot: Effect.succeed,
|
||||
resolveList: () => Effect.die("unused"),
|
||||
listResolved: () => Effect.die("unused"),
|
||||
listPage: () => Effect.die("unused"),
|
||||
listPageResolved: () => Effect.die("unused"),
|
||||
find: () => Effect.die("unused"),
|
||||
grep: () => Effect.die("unused"),
|
||||
isIgnored: () => false,
|
||||
}),
|
||||
)
|
||||
const search = Layer.succeed(
|
||||
LocationSearch.Service,
|
||||
LocationSearch.Service.of({
|
||||
files: () => Effect.die("unused"),
|
||||
grep: (input, root) =>
|
||||
Effect.sync(() => {
|
||||
searches.push(input)
|
||||
if (root) roots.push(root)
|
||||
if (searchFailure) throw searchFailure
|
||||
return result
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
|
||||
const grep = GrepTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(filesystem),
|
||||
Layer.provide(search),
|
||||
Layer.provide(permission),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(registry, filesystem, search, permission, grep))
|
||||
const sessionID = SessionV2.ID.make("ses_grep_tool_test")
|
||||
|
||||
const execute = (input: Record<string, unknown>) =>
|
||||
ToolRegistry.Service.use((registry) =>
|
||||
registry.execute({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }),
|
||||
)
|
||||
|
||||
const settle = (input: Record<string, unknown>) =>
|
||||
ToolRegistry.Service.use((registry) =>
|
||||
registry.settle({ sessionID, call: { type: "tool-call", id: "call-grep", name: "grep", input } }),
|
||||
)
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
searches.length = 0
|
||||
roots.length = 0
|
||||
allow = true
|
||||
searchFailure = undefined
|
||||
result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
|
||||
}
|
||||
|
||||
function references(entries: Record<string, ProjectReference.Resolved>) {
|
||||
return ProjectReference.Service.of({
|
||||
list: () => Effect.succeed(Object.values(entries)),
|
||||
get: (name) => Effect.succeed(entries[name]),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath: () => Effect.void,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
})
|
||||
}
|
||||
|
||||
function provideLive(directory: string, projectReferences = references({})) {
|
||||
const dependencies = Layer.mergeAll(
|
||||
FSUtil.defaultLayer,
|
||||
FileSystemRipgrep.defaultLayer,
|
||||
AppProcess.defaultLayer,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
Layer.succeed(ProjectReference.Service, projectReferences),
|
||||
)
|
||||
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
|
||||
const search = LocationSearch.layer.pipe(
|
||||
Layer.provide(filesystem),
|
||||
Layer.provide(Ripgrep.layer.pipe(Layer.provide(dependencies))),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(dependencies),
|
||||
)
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
|
||||
const grep = GrepTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(filesystem),
|
||||
Layer.provide(search),
|
||||
Layer.provide(permission),
|
||||
)
|
||||
return Layer.mergeAll(registry, filesystem, search, permission, grep)
|
||||
}
|
||||
|
||||
describe("GrepTool", () => {
|
||||
it.effect("registers the grep contribution", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
expect(yield* (yield* ToolRegistry.Service).definitions()).toMatchObject([{ name: "grep" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("authorizes the regex resource and delegates an active Location grep", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
const input = { pattern: "needle", path: "src", include: "*.ts", limit: 2 }
|
||||
|
||||
expect(yield* execute(input)).toEqual({ type: "text", value: "No files found" })
|
||||
expect(assertions).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
action: "grep",
|
||||
resources: ["needle"],
|
||||
save: ["*"],
|
||||
metadata: { root: "src", reference: undefined, path: RelativePath.make("src"), include: "*.ts", limit: 2 },
|
||||
},
|
||||
])
|
||||
expect(searches).toEqual([{ pattern: "needle", path: RelativePath.make("src"), include: "*.ts", limit: 2 }])
|
||||
expect(roots).toMatchObject([{ resource: "src" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("delegates named reference grep and exposes the canonical selected root in metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
|
||||
yield* execute({ pattern: "guide", path: "docs", reference: "manual", include: "*.md" })
|
||||
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: ["guide"],
|
||||
metadata: { root: "manual:docs", reference: "manual", path: RelativePath.make("docs"), include: "*.md" },
|
||||
})
|
||||
expect(searches).toEqual([
|
||||
{ pattern: "guide", path: RelativePath.make("docs"), reference: "manual", include: "*.md" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not search when permission is denied", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
allow = false
|
||||
|
||||
expect(yield* execute({ pattern: "secret" })).toEqual({ type: "error", value: "Unable to grep for secret" })
|
||||
expect(assertions).toHaveLength(1)
|
||||
expect(searches).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps structured results raw while formatting bounded partial previews for models", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
result = new LocationSearch.GrepResult({
|
||||
items: [
|
||||
new LocationSearch.Match({
|
||||
path: RelativePath.make("src/index.ts"),
|
||||
canonical: "/project/src/index.ts",
|
||||
resource: "src/index.ts",
|
||||
lines: "needle preview",
|
||||
linePreviewTruncated: true,
|
||||
line: 3,
|
||||
offset: 8,
|
||||
submatches: [new LocationSearch.Submatch({ text: "needle", start: 0, end: 6 })],
|
||||
mtime: 1,
|
||||
}),
|
||||
],
|
||||
truncated: true,
|
||||
partial: true,
|
||||
})
|
||||
|
||||
const settlement = yield* settle({ pattern: "needle" })
|
||||
expect(settlement.output?.structured).toEqual(result)
|
||||
expect(settlement.result).toEqual({
|
||||
type: "text",
|
||||
value:
|
||||
"Found 1 matches\nsrc/index.ts:\n Line 3: needle preview...\n\n(Results are truncated: showing first 1 matches. Consider using a more specific path or pattern.)\n\n(Some paths were inaccessible and skipped)",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns a useful tool error for an invalid regex", () =>
|
||||
Effect.gen(function* () {
|
||||
reset()
|
||||
searchFailure = new Ripgrep.InvalidPatternError({
|
||||
pattern: "[",
|
||||
message: "regex parse error: unclosed character class",
|
||||
})
|
||||
|
||||
expect(yield* execute({ pattern: "[" })).toEqual({
|
||||
type: "error",
|
||||
value: 'Invalid grep pattern "[": regex parse error: unclosed character class',
|
||||
})
|
||||
expect(searches).toEqual([{ pattern: "[" }])
|
||||
}),
|
||||
)
|
||||
|
||||
runtimeIt.live("greps active Location and named-reference files with include globs", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const docs = path.join(tmp.path, "docs")
|
||||
return Effect.gen(function* () {
|
||||
reset()
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "src"))
|
||||
await fs.mkdir(docs)
|
||||
await fs.writeFile(path.join(tmp.path, "src", "index.ts"), "needle ts\n")
|
||||
await fs.writeFile(path.join(tmp.path, "src", "notes.txt"), "needle txt\n")
|
||||
await fs.writeFile(path.join(docs, "guide.md"), "needle docs\n")
|
||||
})
|
||||
|
||||
expect(yield* execute({ pattern: "needle", path: "src", include: "*.ts" })).toEqual({
|
||||
type: "text",
|
||||
value: "Found 1 matches\nsrc/index.ts:\n Line 1: needle ts\n",
|
||||
})
|
||||
expect(yield* execute({ pattern: "needle", reference: "docs", include: "*.md" })).toEqual({
|
||||
type: "text",
|
||||
value: "Found 1 matches\ndocs:guide.md:\n Line 1: needle docs\n",
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(provideLive(tmp.path, references({ docs: { name: "docs", kind: "local", path: docs } }))),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
265
packages/core/test/tool-output-store.test.ts
Normal file
265
packages/core/test/tool-output-store.test.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigToolOutput } from "@opencode-ai/core/config/tool-output"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_tool_output_store")
|
||||
const otherSessionID = SessionV2.ID.make("ses_tool_output_store_other")
|
||||
|
||||
const withStore = <A, E, R>(
|
||||
body: (input: { root: string; store: ToolOutputStore.Interface; fs: FSUtil.Interface }) => Effect.Effect<A, E, R>,
|
||||
config?: Config.Info,
|
||||
) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
const global = Global.layerWith({ data: tmp.path })
|
||||
const configured = config
|
||||
? Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () => Effect.succeed([new Config.Document({ type: "document", info: config })]),
|
||||
}),
|
||||
)
|
||||
: Layer.empty
|
||||
const store = ToolOutputStore.layer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(global),
|
||||
Layer.provide(configured),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
return yield* body({ root: tmp.path, store: yield* ToolOutputStore.Service, fs: yield* FSUtil.Service })
|
||||
}).pipe(Effect.provide(Layer.mergeAll(store, FSUtil.defaultLayer)))
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("ToolOutputStore", () => {
|
||||
it.live("returns under-limit text unchanged without writing a resource", () =>
|
||||
withStore(({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "line one\nline two" })).toEqual({
|
||||
content: "line one\nline two",
|
||||
truncated: false,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("stores byte-truncated output and returns an opaque head-tail preview", () =>
|
||||
withStore(({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
const content = "HEAD-" + "x".repeat(100) + "-TAIL"
|
||||
const result = yield* store.truncate({ sessionID, toolCallID: "call-bytes", content, maxBytes: 20 })
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
if (!result.truncated) throw new Error("expected truncation")
|
||||
expect(result.content).toContain("HEAD-")
|
||||
expect(result.content).toContain("-TAIL")
|
||||
expect(result.content).toContain("output truncated")
|
||||
expect(result.resource.uri).toMatch(/^tool-output:\/\/[0-9A-Za-z]+$/)
|
||||
expect(result.resource.uri.slice("tool-output://".length)).not.toContain("/")
|
||||
expect(result.resource.uri).not.toContain("\\")
|
||||
expect(result.resource).toMatchObject({ mime: "text/plain", size: Buffer.byteLength(content) })
|
||||
expect((yield* store.read({ sessionID, uri: result.resource.uri })).content).toBe(content)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("stores line-truncated output and keeps both ends in the preview", () =>
|
||||
withStore(({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
const content = Array.from({ length: 10 }, (_, index) => `line-${index}`).join("\n")
|
||||
const result = yield* store.truncate({ sessionID, toolCallID: "call-lines", content, maxLines: 4 })
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
if (!result.truncated) throw new Error("expected truncation")
|
||||
expect(result.content).toContain("line-0\nline-1")
|
||||
expect(result.content).toContain("line-8\nline-9")
|
||||
expect(result.content).not.toContain("line-4")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps one-line previews bounded", () =>
|
||||
withStore(({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* store.truncate({
|
||||
sessionID,
|
||||
toolCallID: "call-one-line",
|
||||
content: "one\ntwo\nthree",
|
||||
maxLines: 1,
|
||||
})
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
if (!result.truncated) throw new Error("expected truncation")
|
||||
const preview = result.content.split("\n\n... output truncated")[0]
|
||||
expect(preview).toBe("one")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("pages reads within the bounded managed-resource limit", () =>
|
||||
withStore(({ root, store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
const resource = yield* store.write({
|
||||
sessionID,
|
||||
toolCallID: "call-page",
|
||||
content: "0123456789",
|
||||
name: "out.txt",
|
||||
})
|
||||
const first = yield* store.read({ sessionID, uri: resource.uri, limit: 4 })
|
||||
const second = yield* store.read({ sessionID, uri: resource.uri, offset: first.next, limit: 4 })
|
||||
const last = yield* store.read({ sessionID, uri: resource.uri, offset: second.next, limit: 4 })
|
||||
|
||||
expect(first).toMatchObject({ content: "0123", offset: 0, truncated: true, next: 4 })
|
||||
expect(second).toMatchObject({ content: "4567", offset: 4, truncated: true, next: 8 })
|
||||
expect(last).toMatchObject({ content: "89", offset: 8, truncated: false })
|
||||
expect(last.resource).toEqual({ uri: resource.uri, mime: "text/plain", name: "out.txt", size: 10 })
|
||||
expect(
|
||||
JSON.parse(
|
||||
yield* fs.readFileString(
|
||||
path.join(root, "tool-output", "managed", `${resource.uri.slice("tool-output://".length)}.json`),
|
||||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
sessionID,
|
||||
toolCallID: "call-page",
|
||||
})
|
||||
|
||||
const bounded = yield* store.read({
|
||||
sessionID,
|
||||
uri: (yield* store.write({
|
||||
sessionID,
|
||||
toolCallID: "call-bounded",
|
||||
content: "x".repeat(ToolOutputStore.MAX_READ_BYTES + 10),
|
||||
})).uri,
|
||||
limit: ToolOutputStore.MAX_READ_BYTES + 10,
|
||||
})
|
||||
expect(Buffer.byteLength(bounded.content)).toBe(ToolOutputStore.MAX_READ_BYTES)
|
||||
expect(bounded).toMatchObject({ truncated: true, next: ToolOutputStore.MAX_READ_BYTES })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows the owning session and denies cross-session reads", () =>
|
||||
withStore(({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
const resource = yield* store.write({ sessionID, toolCallID: "call-owned", content: "owned" })
|
||||
expect((yield* store.read({ sessionID, uri: resource.uri })).content).toBe("owned")
|
||||
expect(yield* Effect.flip(store.read({ sessionID: otherSessionID, uri: resource.uri }))).toBeInstanceOf(
|
||||
ToolOutputStore.AccessDeniedError,
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects resources whose payload size no longer matches metadata", () =>
|
||||
withStore(({ root, store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
const resource = yield* store.write({ sessionID, toolCallID: "call-modified", content: "original" })
|
||||
const id = resource.uri.slice("tool-output://".length)
|
||||
yield* fs.writeFileString(path.join(root, "tool-output", "managed", `${id}.txt`), "changed payload")
|
||||
|
||||
expect(yield* Effect.flip(store.read({ sessionID, uri: resource.uri }))).toBeInstanceOf(
|
||||
ToolOutputStore.ResourceNotFoundError,
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("honors configured truncation limits", () =>
|
||||
withStore(
|
||||
({ store }) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 })
|
||||
expect(
|
||||
(yield* store.truncate({ sessionID, toolCallID: "call-config", content: "one\ntwo\nthree" })).truncated,
|
||||
).toBe(true)
|
||||
}),
|
||||
new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("cleans old managed resources while preserving recent and unrelated files", () =>
|
||||
withStore(({ root, store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
const old = yield* store.write({ sessionID, toolCallID: "call-old", content: "old" })
|
||||
const recent = yield* store.write({ sessionID, toolCallID: "call-recent", content: "recent" })
|
||||
const directory = path.join(root, "tool-output", "managed")
|
||||
const oldID = old.uri.slice("tool-output://".length)
|
||||
const recentID = recent.uri.slice("tool-output://".length)
|
||||
const oldMetadata = path.join(directory, `${oldID}.json`)
|
||||
const unrelated = path.join(root, "tool-output", "unrelated.txt")
|
||||
const unrelatedManaged = path.join(directory, "unrelated.txt")
|
||||
const record = JSON.parse(yield* fs.readFileString(oldMetadata))
|
||||
|
||||
yield* fs.writeFileString(
|
||||
oldMetadata,
|
||||
JSON.stringify({ ...record, created: Date.now() - 8 * 24 * 60 * 60 * 1_000 }),
|
||||
)
|
||||
yield* fs.writeFileString(unrelated, "keep")
|
||||
yield* fs.writeFileString(unrelatedManaged, "keep")
|
||||
yield* store.cleanup()
|
||||
|
||||
expect(yield* fs.exists(path.join(directory, `${oldID}.txt`))).toBe(false)
|
||||
expect(yield* fs.exists(oldMetadata)).toBe(false)
|
||||
expect(yield* fs.exists(path.join(directory, `${recentID}.txt`))).toBe(true)
|
||||
expect(yield* fs.exists(unrelated)).toBe(true)
|
||||
expect(yield* fs.exists(unrelatedManaged)).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("cleans stale generated orphan payloads and malformed pairs", () =>
|
||||
withStore(({ root, store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(root, "tool-output", "managed")
|
||||
yield* fs.ensureDir(directory)
|
||||
const orphanID = "00000000000000000000000000"
|
||||
const malformedID = "00000000000000000000000001"
|
||||
const orphan = path.join(directory, `${orphanID}.txt`)
|
||||
const malformedPayload = path.join(directory, `${malformedID}.txt`)
|
||||
const malformedMetadata = path.join(directory, `${malformedID}.json`)
|
||||
yield* fs.writeFileString(orphan, "orphan")
|
||||
yield* fs.writeFileString(malformedPayload, "malformed")
|
||||
yield* fs.writeFileString(malformedMetadata, "not json")
|
||||
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000)
|
||||
yield* Effect.all([fs.utimes(orphan, old, old), fs.utimes(malformedPayload, old, old)])
|
||||
|
||||
yield* store.cleanup()
|
||||
|
||||
expect(yield* fs.exists(orphan)).toBe(false)
|
||||
expect(yield* fs.exists(malformedPayload)).toBe(false)
|
||||
expect(yield* fs.exists(malformedMetadata)).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("cleans managed resources whose payload size no longer matches metadata", () =>
|
||||
withStore(({ root, store, fs }) =>
|
||||
Effect.gen(function* () {
|
||||
const resource = yield* store.write({ sessionID, toolCallID: "call-modified", content: "original" })
|
||||
const directory = path.join(root, "tool-output", "managed")
|
||||
const id = resource.uri.slice("tool-output://".length)
|
||||
const payload = path.join(directory, `${id}.txt`)
|
||||
const metadata = path.join(directory, `${id}.json`)
|
||||
yield* fs.writeFileString(payload, "changed payload")
|
||||
|
||||
yield* store.cleanup()
|
||||
|
||||
expect(yield* fs.exists(payload)).toBe(false)
|
||||
expect(yield* fs.exists(metadata)).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
119
packages/core/test/tool-question.test.ts
Normal file
119
packages/core/test/tool-question.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
|
||||
import { QuestionTool } from "@opencode-ai/core/tool/question"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_question_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
let captured: QuestionV2.AskInput | undefined
|
||||
let reject = false
|
||||
const capturedInput = () => captured
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) => Effect.sync(() => assertions.push(input)),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
|
||||
const question = Layer.succeed(
|
||||
QuestionV2.Service,
|
||||
QuestionV2.Service.of({
|
||||
ask: (input: QuestionV2.AskInput) =>
|
||||
Effect.sync(() => {
|
||||
captured = input
|
||||
}).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
|
||||
reply: () => Effect.die("unused"),
|
||||
reject: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(question))
|
||||
const it = testEffect(Layer.mergeAll(permission, registry, question, tool))
|
||||
|
||||
describe("QuestionTool", () => {
|
||||
it.effect("registers question and projects user answers without a permission assertion", () =>
|
||||
Effect.gen(function* () {
|
||||
assertions.length = 0
|
||||
captured = undefined
|
||||
reject = false
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const questions = [
|
||||
{
|
||||
question: "What should happen?",
|
||||
header: "Action",
|
||||
options: [{ label: "Build", description: "Build it" }],
|
||||
},
|
||||
{
|
||||
question: "Which environment?",
|
||||
header: "Environment",
|
||||
options: [{ label: "Dev", description: "Development" }],
|
||||
},
|
||||
]
|
||||
|
||||
expect((yield* registry.definitions()).map((definition) => definition.name)).toEqual(["question"])
|
||||
expect(
|
||||
yield* registry.settle({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-question", name: "question", input: { questions } },
|
||||
}),
|
||||
).toEqual({
|
||||
result: {
|
||||
type: "text",
|
||||
value:
|
||||
'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
|
||||
},
|
||||
output: {
|
||||
structured: { answers: [["Build"], []] },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(assertions).toEqual([])
|
||||
expect(capturedInput()).toEqual({ sessionID, questions, tool: undefined })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not invent tool ownership metadata without a durable registry source", () =>
|
||||
Effect.gen(function* () {
|
||||
captured = undefined
|
||||
reject = false
|
||||
const registryService = yield* ToolRegistry.Service
|
||||
|
||||
yield* registryService.execute({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
|
||||
})
|
||||
expect(capturedInput()).toEqual({ sessionID, questions: [], tool: undefined })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps dismissed questions out of model-facing output", () =>
|
||||
Effect.gen(function* () {
|
||||
captured = undefined
|
||||
reject = true
|
||||
const registryService = yield* ToolRegistry.Service
|
||||
const fiber = yield* registryService
|
||||
.execute({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
|
||||
})
|
||||
.pipe(Effect.forkScoped)
|
||||
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
402
packages/core/test/tool-read.test.ts
Normal file
402
packages/core/test/tool-read.test.ts
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
|
||||
import { ReadTool } from "@opencode-ai/core/tool/read"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { RelativePath } from "@opencode-ai/core/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const reads: FileSystem.ReadInput[] = []
|
||||
const textPageInputs: FileSystem.TextPageInput[] = []
|
||||
const pages: FileSystem.ListTarget[] = []
|
||||
const pageInputs: Pick<FileSystem.ListPageInput, "offset" | "limit">[] = []
|
||||
let resolvedInput: FileSystem.ReadInput | undefined
|
||||
let resolveFailure: unknown
|
||||
let listResolveFailure: unknown = new Error("not a directory")
|
||||
let listReal = "/project/src"
|
||||
let size = 5
|
||||
let real = "/project/README.md"
|
||||
let afterApproval = () => {}
|
||||
const resourceReads: ToolOutputStore.ReadInput[] = []
|
||||
const filesystem = Layer.succeed(
|
||||
FileSystem.Service,
|
||||
FileSystem.Service.of({
|
||||
read: () => Effect.die("unused"),
|
||||
resolveReadPath: (input) =>
|
||||
resolveFailure === undefined
|
||||
? Effect.succeed({
|
||||
type: "file" as const,
|
||||
target: new FileSystem.ReadTarget({
|
||||
real,
|
||||
resource: input.reference === undefined ? "README.md" : `${input.reference}:README.md`,
|
||||
size,
|
||||
dev: 1,
|
||||
}),
|
||||
})
|
||||
: listResolveFailure === undefined
|
||||
? Effect.succeed({
|
||||
type: "directory" as const,
|
||||
target: new FileSystem.ListTarget({
|
||||
absolute: `/project/${input.path ?? "."}`,
|
||||
real: listReal,
|
||||
directory: "/project",
|
||||
root: "/project",
|
||||
resource: input.path ?? ".",
|
||||
}),
|
||||
})
|
||||
: Effect.die(resolveFailure),
|
||||
resolveRead: (input) =>
|
||||
Effect.sync(() => {
|
||||
resolvedInput = input
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
resolveFailure === undefined
|
||||
? Effect.succeed(
|
||||
new FileSystem.ReadTarget({
|
||||
real,
|
||||
resource: input.reference === undefined ? "README.md" : `${input.reference}:README.md`,
|
||||
size,
|
||||
dev: 1,
|
||||
}),
|
||||
)
|
||||
: Effect.die(resolveFailure),
|
||||
),
|
||||
),
|
||||
readResolved: () =>
|
||||
Effect.sync(() => {
|
||||
reads.push({ path: RelativePath.make("README.md") })
|
||||
return new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
|
||||
}),
|
||||
readTextPageResolved: (_target, page = {}) =>
|
||||
Effect.sync(() => {
|
||||
textPageInputs.push(page)
|
||||
return new FileSystem.TextPage({
|
||||
type: "text-page",
|
||||
content: "hello",
|
||||
mime: "text/plain",
|
||||
offset: page.offset ?? 1,
|
||||
truncated: true,
|
||||
next: (page.offset ?? 1) + 1,
|
||||
})
|
||||
}),
|
||||
resolveRoot: () => Effect.die("unused"),
|
||||
revalidateRoot: Effect.succeed,
|
||||
list: () => Effect.die("unused"),
|
||||
resolveList: (input = {}) =>
|
||||
listResolveFailure === undefined
|
||||
? Effect.succeed(
|
||||
new FileSystem.ListTarget({
|
||||
absolute: `/project/${input.path ?? "."}`,
|
||||
real: listReal,
|
||||
directory: "/project",
|
||||
root: "/project",
|
||||
resource: input.path ?? ".",
|
||||
}),
|
||||
)
|
||||
: Effect.die(listResolveFailure),
|
||||
listResolved: () => Effect.die("unused"),
|
||||
listPage: () => Effect.die("unused"),
|
||||
listPageResolved: (target, page = {}) =>
|
||||
Effect.sync(() => {
|
||||
pages.push(target)
|
||||
pageInputs.push(page)
|
||||
return new FileSystem.ListPage({ entries: [], truncated: false })
|
||||
}),
|
||||
find: () => Effect.die("unused"),
|
||||
grep: () => Effect.die("unused"),
|
||||
isIgnored: () => false,
|
||||
}),
|
||||
)
|
||||
let allow = true
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
if (allow) afterApproval()
|
||||
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(permission))
|
||||
const resources = Layer.succeed(
|
||||
ToolOutputStore.Service,
|
||||
ToolOutputStore.Service.of({
|
||||
limits: () => Effect.die("unused"),
|
||||
write: () => Effect.die("unused"),
|
||||
truncate: () => Effect.die("unused"),
|
||||
cleanup: () => Effect.die("unused"),
|
||||
read: (input) =>
|
||||
Effect.sync(() => {
|
||||
resourceReads.push(input)
|
||||
return new ToolOutputStore.Page({
|
||||
resource: new ToolOutputStore.Resource({ uri: input.uri, mime: "text/plain", size: 5 }),
|
||||
content: "hello",
|
||||
offset: input.offset ?? 0,
|
||||
truncated: false,
|
||||
})
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const read = ReadTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(filesystem),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(resources),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, resources, read))
|
||||
const sessionID = SessionV2.ID.make("ses_read_tool_test")
|
||||
|
||||
describe("ReadTool", () => {
|
||||
it.effect("registers, authorizes, and reads through the location filesystem", () =>
|
||||
Effect.gen(function* () {
|
||||
assertions.length = 0
|
||||
reads.length = 0
|
||||
allow = true
|
||||
resolveFailure = undefined
|
||||
listResolveFailure = new Error("not a directory")
|
||||
size = 5
|
||||
real = "/project/README.md"
|
||||
afterApproval = () => {}
|
||||
resolvedInput = undefined
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(yield* registry.definitions()).toMatchObject([{ name: "read" }])
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
|
||||
}),
|
||||
).toEqual({ type: "json", value: { type: "text", content: "hello", mime: "text/plain" } })
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }])
|
||||
expect(reads).toEqual([{ path: RelativePath.make("README.md") }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not read when permission is denied", () =>
|
||||
Effect.gen(function* () {
|
||||
assertions.length = 0
|
||||
reads.length = 0
|
||||
allow = false
|
||||
resolveFailure = undefined
|
||||
listResolveFailure = new Error("not a directory")
|
||||
size = 5
|
||||
real = "/project/README.md"
|
||||
afterApproval = () => {}
|
||||
resolvedInput = undefined
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to read README.md" })
|
||||
expect(reads).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads an opaque managed resource without treating it as a path", () =>
|
||||
Effect.gen(function* () {
|
||||
resourceReads.length = 0
|
||||
assertions.length = 0
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-read-resource",
|
||||
name: "read",
|
||||
input: { resource: "tool-output://opaque", offset: 2, limit: 10 },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "json",
|
||||
value: {
|
||||
resource: { uri: "tool-output://opaque", mime: "text/plain", size: 5 },
|
||||
content: "hello",
|
||||
offset: 2,
|
||||
truncated: false,
|
||||
},
|
||||
})
|
||||
expect(resourceReads).toEqual([{ sessionID, uri: "tool-output://opaque", offset: 2, limit: 10 }])
|
||||
expect(assertions).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lists a bounded directory page through read", () =>
|
||||
Effect.gen(function* () {
|
||||
assertions.length = 0
|
||||
pages.length = 0
|
||||
pageInputs.length = 0
|
||||
allow = true
|
||||
resolveFailure = new Error("Path is not a file")
|
||||
listResolveFailure = undefined
|
||||
listReal = "/project/src"
|
||||
afterApproval = () => {}
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-read-directory",
|
||||
name: "read",
|
||||
input: { path: "src", offset: 2, limit: 10 },
|
||||
},
|
||||
}),
|
||||
).toEqual({ type: "json", value: { entries: [], truncated: false } })
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
|
||||
expect(pageInputs).toEqual([{ offset: 2, limit: 10 }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not list a directory when permission is denied", () =>
|
||||
Effect.gen(function* () {
|
||||
pages.length = 0
|
||||
allow = false
|
||||
resolveFailure = new Error("Path is not a file")
|
||||
listResolveFailure = undefined
|
||||
listReal = "/project/src"
|
||||
afterApproval = () => {}
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to read src" })
|
||||
expect(pages).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not list when the directory changes after permission approval", () =>
|
||||
Effect.gen(function* () {
|
||||
pages.length = 0
|
||||
allow = true
|
||||
resolveFailure = new Error("Path is not a file")
|
||||
listResolveFailure = undefined
|
||||
listReal = "/project/src"
|
||||
afterApproval = () => {
|
||||
listReal = "/outside/src"
|
||||
}
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-read-directory-swapped", name: "read", input: { path: "src" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to read src" })
|
||||
expect(pages).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("authorizes project references with their canonical identity", () =>
|
||||
Effect.gen(function* () {
|
||||
assertions.length = 0
|
||||
reads.length = 0
|
||||
allow = true
|
||||
resolveFailure = undefined
|
||||
listResolveFailure = new Error("not a directory")
|
||||
size = 5
|
||||
real = "/project/README.md"
|
||||
afterApproval = () => {}
|
||||
resolvedInput = undefined
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
|
||||
})
|
||||
|
||||
expect(assertions).toMatchObject([{ resources: ["docs:README.md"] }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles missing files as typed tool errors", () =>
|
||||
Effect.gen(function* () {
|
||||
allow = true
|
||||
reads.length = 0
|
||||
real = "/project/README.md"
|
||||
afterApproval = () => {}
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
resolveFailure = new Error("missing")
|
||||
listResolveFailure = new Error("missing")
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to read missing.txt" })
|
||||
|
||||
expect(reads).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads large UTF-8 text files as bounded pages with continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
textPageInputs.length = 0
|
||||
allow = true
|
||||
resolveFailure = undefined
|
||||
listResolveFailure = new Error("not a directory")
|
||||
size = FileSystem.MAX_READ_BYTES + 1
|
||||
real = "/project/large.txt"
|
||||
afterApproval = () => {}
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-large",
|
||||
name: "read",
|
||||
input: { path: "large.txt", offset: 2, limit: 1 },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "json",
|
||||
value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 },
|
||||
})
|
||||
expect(textPageInputs).toEqual([{ offset: 2, limit: 1 }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not read when the file changes after permission approval", () =>
|
||||
Effect.gen(function* () {
|
||||
assertions.length = 0
|
||||
reads.length = 0
|
||||
allow = true
|
||||
resolveFailure = undefined
|
||||
listResolveFailure = new Error("not a directory")
|
||||
size = 5
|
||||
real = "/project/README.md"
|
||||
afterApproval = () => {
|
||||
real = "/outside/README.md"
|
||||
}
|
||||
const registry = yield* ToolRegistry.Service
|
||||
expect(
|
||||
yield* registry.execute({
|
||||
sessionID,
|
||||
call: { type: "tool-call", id: "call-swapped", name: "read", input: { path: "README.md" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Unable to read README.md" })
|
||||
expect(reads).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue