Merge remote-tracking branch 'origin/v2' into nxl/vcs-plugin-api
# Conflicts: # packages/core/src/config.ts # packages/core/src/plugin/internal.ts # packages/core/src/project.ts # packages/core/test/project.test.ts # packages/plugin/src/v2/effect/context.ts # packages/plugin/src/v2/effect/index.ts
This commit is contained in:
commit
8ede176244
599 changed files with 83198 additions and 11394 deletions
|
|
@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
|
|
@ -24,7 +23,7 @@ const locationLayer = Layer.succeed(
|
|||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
||||
)
|
||||
const catalogLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]),
|
||||
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node]),
|
||||
[[Location.node, locationLayer]],
|
||||
)
|
||||
const it = testEffect(catalogLayer)
|
||||
|
|
@ -333,21 +332,4 @@ describe("CatalogV2", () => {
|
|||
expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes providers denied by policy after loading", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const policy = yield* Policy.Service
|
||||
const providerID = ProviderV2.ID.make("blocked")
|
||||
yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
|
||||
})
|
||||
|
||||
expect(yield* catalog.provider.all()).toEqual([])
|
||||
expect(yield* catalog.model.all()).toEqual([])
|
||||
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, PubSub, Schema, Stream } from "effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
|
@ -19,7 +21,7 @@ import { testEffect } from "../lib/effect"
|
|||
import { host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([CommandV2.node, EventV2.node, FSUtil.node]), [
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Config.node, emptyConfigLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
|
|
@ -53,7 +55,19 @@ Review files`,
|
|||
})
|
||||
|
||||
const command = yield* CommandV2.Service
|
||||
yield* ConfigCommandPlugin.Plugin.effect(host({ command: { ...command, reload: command.reload } })).pipe(
|
||||
const events = yield* EventV2.Service
|
||||
const update = yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
const updates = yield* PubSub.unbounded<typeof update>()
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
list: () => Effect.die("unused command.list"),
|
||||
transform: command.transform,
|
||||
reload: command.reload,
|
||||
},
|
||||
event: { subscribe: () => Stream.fromPubSub(updates) },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
|
|
@ -85,6 +99,15 @@ Review files`,
|
|||
CommandV2.Info.make({ name: "empty", template: "" }),
|
||||
CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }),
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* PubSub.publish(updates, update)
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((yield* command.get("review"))?.template === "Review again") break
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
expect((yield* command.get("review"))?.template).toBe("Review again")
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
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 { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
|
|
@ -26,6 +28,7 @@ function testLayer(
|
|||
globalDirectory = path.join(directory, "global"),
|
||||
projectDirectory = directory,
|
||||
vcs?: Project.Vcs,
|
||||
watcher?: Layer.Layer<Watcher.Service>,
|
||||
) {
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
|
|
@ -36,9 +39,10 @@ function testLayer(
|
|||
),
|
||||
),
|
||||
)
|
||||
return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [
|
||||
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: globalDirectory })],
|
||||
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
||||
])
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +56,52 @@ const provider = {
|
|||
}
|
||||
|
||||
describe("Config", () => {
|
||||
it.live("reloads external config and publishes directory updates", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const file = path.join(global, "opencode.json")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.mkdir(project, { recursive: true })
|
||||
await fs.writeFile(file, JSON.stringify({ shell: "first" }))
|
||||
})
|
||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||
const watcher = Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: () => Stream.fromPubSub(updates),
|
||||
}),
|
||||
)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const events = yield* EventV2.Service
|
||||
const changed = yield* events
|
||||
.subscribe(ConfigSchema.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
yield* PubSub.publish(updates, {
|
||||
type: "update",
|
||||
path: path.join(global, "commands", "review.md"),
|
||||
} satisfies Watcher.Update)
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
|
||||
yield* PubSub.publish(updates, { type: "update", path: file } satisfies Watcher.Update)
|
||||
|
||||
expect(yield* Fiber.join(changed)).toHaveLength(1)
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
|
||||
}).pipe(Effect.provide(testLayer(project, global, project, undefined, watcher)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
|
||||
Effect.sync(() => {
|
||||
const entries = [
|
||||
|
|
@ -242,6 +292,72 @@ describe("Config", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("substitutes environment variables and relative file contents", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = {
|
||||
token: process.env.OPENCODE_TEST_MCP_TOKEN,
|
||||
missing: process.env.OPENCODE_TEST_MISSING,
|
||||
}
|
||||
process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
|
||||
delete process.env.OPENCODE_TEST_MISSING
|
||||
return previous
|
||||
}),
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.jsonc"),
|
||||
`{
|
||||
// Ignored reference: {file:missing.txt}
|
||||
"username": "user-{env:OPENCODE_TEST_MISSING}",
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"remote": {
|
||||
"type": "remote",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
|
||||
"X-Token": "{file:token.txt}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
),
|
||||
]),
|
||||
)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const document = (yield* config.entries()).find((entry) => entry.type === "document")
|
||||
expect(document?.info.username).toBe("user-")
|
||||
const remote = document?.info.mcp?.servers?.remote
|
||||
expect(remote?.type).toBe("remote")
|
||||
if (remote?.type !== "remote") return
|
||||
expect(remote.headers).toEqual({
|
||||
Authorization: "Bearer secret",
|
||||
"X-Token": 'file\n"token"',
|
||||
})
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
|
||||
else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
|
||||
if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
|
||||
else process.env.OPENCODE_TEST_MISSING = previous.missing
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not load legacy config.json files", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
@ -274,7 +390,6 @@ describe("Config", () => {
|
|||
const file = path.join(tmp.path, "opencode.json")
|
||||
const contents = JSON.stringify({
|
||||
shell: "/bin/zsh",
|
||||
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
|
||||
providers: { local: provider },
|
||||
})
|
||||
yield* Effect.promise(() => fs.writeFile(file, contents))
|
||||
|
|
@ -285,11 +400,6 @@ describe("Config", () => {
|
|||
|
||||
expect(documents[0]?.info.$schema).toBeUndefined()
|
||||
expect(documents[0]?.info.shell).toBe("/bin/zsh")
|
||||
expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({
|
||||
effect: "deny",
|
||||
action: "provider.use",
|
||||
resource: "openai",
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
|
|
@ -723,40 +833,6 @@ describe("Config", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("loads policy statements in reverse config order", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(global, "opencode.json"),
|
||||
JSON.stringify({
|
||||
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
|
||||
}),
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
|
||||
}).pipe(Effect.provide(testLayer(tmp.path, global)))
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
|
|||
|
|
@ -1,252 +1,298 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect } from "effect"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])),
|
||||
)
|
||||
|
||||
describe("ConfigExternalPlugin", () => {
|
||||
it.live("resolves and loads a configured Promise plugin with options", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
describe("PluginSupervisor config", () => {
|
||||
it.live("applies selectors in order", () =>
|
||||
withLocation(
|
||||
{ plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
yield* ready()
|
||||
expect(
|
||||
(yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
|
||||
).toEqual([Plugin.ID.make("opencode.provider.openai")])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
|
||||
Effect.provideService(PluginV2.Service, plugins),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
path: document,
|
||||
info: decode({
|
||||
plugins: [
|
||||
{
|
||||
package: "../plugin/fixtures/config-promise-plugin.ts",
|
||||
options: { description: "Loaded from config" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
it.live("loads configured Promise plugins with options", () =>
|
||||
withLocation(
|
||||
{
|
||||
plugins: [
|
||||
"-*",
|
||||
{
|
||||
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
options: { description: "Loaded from config" },
|
||||
},
|
||||
],
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* AgentV2.Service
|
||||
expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({
|
||||
description: "Loaded from config",
|
||||
mode: "subagent",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads configured Effect plugins with options", () =>
|
||||
withLocation(
|
||||
{
|
||||
plugins: [
|
||||
"-*",
|
||||
{
|
||||
package: path.join(import.meta.dir, "../plugin/fixtures/config-effect-plugin.ts"),
|
||||
options: { description: "Effect plugin from config" },
|
||||
},
|
||||
],
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* AgentV2.Service
|
||||
expect(yield* agents.get(AgentV2.ID.make("effect-configured"))).toMatchObject({
|
||||
description: "Effect plugin from config",
|
||||
mode: "subagent",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("ignores invalid packages and continues loading", () =>
|
||||
withLocation(
|
||||
{
|
||||
plugins: [
|
||||
"-*",
|
||||
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
|
||||
{
|
||||
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
options: { description: "Loaded after invalid plugins" },
|
||||
},
|
||||
],
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* AgentV2.Service
|
||||
expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({
|
||||
description: "Loaded after invalid plugins",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads auto-discovered plugin files and packages", () =>
|
||||
withLocation(
|
||||
undefined,
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* AgentV2.Service
|
||||
expect(yield* agents.get(AgentV2.ID.make("directory"))).toMatchObject({
|
||||
description: "Loaded from plugin directory",
|
||||
})
|
||||
expect(yield* agents.get(AgentV2.ID.make("folder"))).toMatchObject({
|
||||
description: "Loaded from plugin folder",
|
||||
})
|
||||
}),
|
||||
true,
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads an auto-discovered plugin when its file changes", () =>
|
||||
withLocation(
|
||||
undefined,
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* AgentV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const location = yield* Location.Service
|
||||
const plugins = yield* PluginV2.Service
|
||||
const file = path.join(location.directory, ".opencode", "plugin", "mutable.ts")
|
||||
const first = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
|
||||
|
||||
expect(first).toBeDefined()
|
||||
expect((yield* agents.get(AgentV2.ID.make("mutable")))?.description).toBe("first")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(file, mutablePlugin("second"))
|
||||
const modified = new Date(Date.now() + 5_000)
|
||||
await fs.utimes(file, modified, modified)
|
||||
})
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* waitUntil(
|
||||
Effect.gen(function* () {
|
||||
const current = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
|
||||
return current === first && (yield* agents.get(AgentV2.ID.make("mutable")))?.description === "second"
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
}),
|
||||
false,
|
||||
async (directory) => {
|
||||
const plugin = path.join(directory, ".opencode", "plugin")
|
||||
await fs.mkdir(plugin, { recursive: true })
|
||||
await fs.writeFile(path.join(plugin, "mutable.ts"), mutablePlugin("first"))
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* waitForAgent(agents, "configured")).toMatchObject({
|
||||
description: "Loaded from config",
|
||||
mode: "subagent",
|
||||
})
|
||||
it.live("applies explicit removals after auto-discovery", () =>
|
||||
withLocation(
|
||||
{ plugins: ["-*"] },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const agents = yield* AgentV2.Service
|
||||
expect(yield* agents.get(AgentV2.ID.make("directory"))).toBeUndefined()
|
||||
expect(yield* agents.get(AgentV2.ID.make("folder"))).toBeUndefined()
|
||||
}),
|
||||
true,
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads user plugins before internal post plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{
|
||||
plugins: [
|
||||
path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"),
|
||||
],
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const registry = yield* PluginV2.Service
|
||||
const ids = (yield* registry.list()).map((plugin) => String(plugin.id))
|
||||
expect(ids.indexOf("opencode.agent")).toBeLessThan(ids.indexOf("sdk-order"))
|
||||
expect(ids.indexOf("sdk-order")).toBeLessThan(ids.indexOf("config-promise-plugin"))
|
||||
expect(ids.indexOf("config-promise-plugin")).toBeLessThan(ids.indexOf("variant-source"))
|
||||
expect(ids.indexOf("variant-source")).toBeLessThan(ids.indexOf("opencode.config.provider"))
|
||||
expect(ids.indexOf("opencode.config.provider")).toBeLessThan(ids.indexOf("opencode.variant"))
|
||||
|
||||
const catalog = yield* Catalog.Service
|
||||
expect(
|
||||
(yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants,
|
||||
).toEqual([
|
||||
expect.objectContaining({ id: "high", headers: { custom: "true" } }),
|
||||
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads a configured Effect plugin with options", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
it.live("allows variant generation to be disabled", () =>
|
||||
withLocation(
|
||||
{
|
||||
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"],
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const registry = yield* PluginV2.Service
|
||||
expect((yield* registry.list()).map((plugin) => String(plugin.id))).not.toContain("opencode.variant")
|
||||
|
||||
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
|
||||
Effect.provideService(PluginV2.Service, plugins),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
path: path.join(import.meta.dir, "opencode.json"),
|
||||
info: decode({
|
||||
plugins: [
|
||||
{
|
||||
package: "../plugin/fixtures/config-effect-plugin.ts",
|
||||
options: { description: "Effect plugin from config" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* waitForAgent(agents, "effect-configured")).toMatchObject({
|
||||
description: "Effect plugin from config",
|
||||
mode: "subagent",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores invalid plugins and continues loading", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
|
||||
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
|
||||
Effect.provideService(PluginV2.Service, plugins),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
path: path.join(import.meta.dir, "opencode.json"),
|
||||
info: decode({
|
||||
plugins: [
|
||||
"../plugin/fixtures/missing-plugin.ts",
|
||||
"../plugin/fixtures/invalid-plugin.ts",
|
||||
{
|
||||
package: "../plugin/fixtures/config-promise-plugin.ts",
|
||||
options: { description: "Loaded after invalid plugins" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* waitForAgent(agents, "configured")).toMatchObject({
|
||||
description: "Loaded after invalid plugins",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("installs and resolves npm plugin packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
let installed: string | undefined
|
||||
const npm = Npm.Service.of({
|
||||
add: (spec) =>
|
||||
Effect.sync(() => {
|
||||
installed = spec
|
||||
return {
|
||||
directory: import.meta.dir,
|
||||
entrypoint: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
|
||||
}
|
||||
}),
|
||||
install: () => Effect.void,
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
|
||||
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
|
||||
Effect.provideService(PluginV2.Service, plugins),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
plugins: [
|
||||
{
|
||||
package: "example-plugin@1.0.0",
|
||||
options: { description: "Installed from npm" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* waitForAgent(agents, "configured")).toMatchObject({
|
||||
description: "Installed from npm",
|
||||
})
|
||||
expect(installed).toBe("example-plugin@1.0.0")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads plugin files from config directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
|
||||
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
|
||||
Effect.provideService(PluginV2.Service, plugins),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Location.Service, location),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Directory({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(import.meta.dir, "fixtures")),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* waitForAgent(agents, "directory")).toMatchObject({
|
||||
description: "Loaded from plugin directory",
|
||||
mode: "subagent",
|
||||
})
|
||||
expect(yield* waitForAgent(agents, "folder")).toMatchObject({
|
||||
description: "Loaded from plugin folder",
|
||||
mode: "subagent",
|
||||
})
|
||||
}),
|
||||
const catalog = yield* Catalog.Service
|
||||
expect(
|
||||
(yield* catalog.model.get(ProviderV2.ID.make("configured"), ModelV2.ID.make("glm-5.2")))?.variants,
|
||||
).toEqual([expect.objectContaining({ id: "high", headers: { custom: "true" } })])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const agent = yield* agents.get(AgentV2.ID.make(id))
|
||||
if (agent) return agent
|
||||
const ready = Effect.fnUntraced(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.ready
|
||||
})
|
||||
|
||||
function withLocation<A, E, R>(
|
||||
config: unknown,
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
fixtures = false,
|
||||
prepare?: (directory: string) => Promise<void>,
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.tap((tmp) =>
|
||||
Effect.promise(async () => {
|
||||
await prepare?.(tmp.path)
|
||||
if (fixtures) {
|
||||
const directory = path.join(tmp.path, ".opencode")
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await Promise.all(
|
||||
["plugin", "plugins"].map((name) =>
|
||||
fs.symlink(path.join(import.meta.dir, "fixtures", name), path.join(directory, name), "dir"),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (config !== undefined) {
|
||||
const directory = fixtures ? path.join(tmp.path, ".opencode") : tmp.path
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify(config))
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.flatMap((tmp) =>
|
||||
effect.pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function mutablePlugin(description: string) {
|
||||
const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/v2/promise/index.ts")).href
|
||||
return `
|
||||
import { define } from ${JSON.stringify(plugin)}
|
||||
|
||||
export default define({
|
||||
id: "mutable-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("mutable", (agent) => {
|
||||
agent.description = ${JSON.stringify(description)}
|
||||
agent.mode = "subagent"
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if (yield* condition) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
return yield* Effect.die(`Timed out waiting for agent ${id}`)
|
||||
return yield* Effect.die("Timed out waiting for plugin reload")
|
||||
})
|
||||
|
|
|
|||
108
packages/core/test/config/reload.test.ts
Normal file
108
packages/core/test/config/reload.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
||||
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
||||
import { ConfigReferencePlugin } from "@opencode-ai/core/config/plugin/reference"
|
||||
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
describe("config plugin reloads", () => {
|
||||
it.live("reloads config-backed domains without reloading external plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const plugins = yield* PluginV2.Service
|
||||
const references = yield* Reference.Service
|
||||
const skills = yield* SkillV2.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
let entries: Config.Entry[] = [config("first")]
|
||||
const service = Config.Service.of({ entries: () => Effect.sync(() => entries) })
|
||||
const setup = <R>(effect: Effect.Effect<void, never, R>) =>
|
||||
effect.pipe(Effect.provideService(Config.Service, service))
|
||||
|
||||
yield* setup(ConfigAgentPlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigCommandPlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigSkillPlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigReferencePlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigProviderPlugin.Plugin.effect(host))
|
||||
|
||||
expect((yield* agents.get(AgentV2.ID.make("first")))?.description).toBe("First agent")
|
||||
expect((yield* commands.get("first"))?.description).toBe("First command")
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
|
||||
).toBe(true)
|
||||
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
|
||||
expect(yield* catalog.provider.get(ProviderV2.ID.make("first"))).toBeDefined()
|
||||
|
||||
entries = [config("second")]
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* waitUntil(
|
||||
Effect.gen(function* () {
|
||||
return (
|
||||
(yield* agents.get(AgentV2.ID.make("first"))) === undefined &&
|
||||
(yield* agents.get(AgentV2.ID.make("second")))?.description === "Second agent" &&
|
||||
(yield* commands.get("first")) === undefined &&
|
||||
(yield* commands.get("second"))?.description === "Second command" &&
|
||||
(yield* references.list()).some((reference) => reference.name === "second") &&
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("first"))) === undefined &&
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("second"))) !== undefined
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
|
||||
).toBe(false)
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
|
||||
).toBe(true)
|
||||
}).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))),
|
||||
)
|
||||
})
|
||||
|
||||
function config(name: string) {
|
||||
return new Config.Document({
|
||||
type: "document",
|
||||
path: document,
|
||||
info: decode({
|
||||
agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } },
|
||||
commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } },
|
||||
skills: [`/skills/${name}`],
|
||||
references: { [name]: `/references/${name}` },
|
||||
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function title(value: string) {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
|
||||
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (yield* condition) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
return yield* Effect.die("Timed out waiting for config plugin reloads")
|
||||
})
|
||||
|
|
@ -36,7 +36,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
|||
|
||||
yield* ConfigSkillPlugin.Plugin.effect(
|
||||
host({
|
||||
skill: { transform, reload: () => Effect.void },
|
||||
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })),
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ describe("DatabaseMigration", () => {
|
|||
)
|
||||
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 9)`)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('event', 'session', 9, 'session.updated.1', '{}')`,
|
||||
sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ describe("node build", () => {
|
|||
Effect.sync(() => {
|
||||
acquisitions++
|
||||
return Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
directories: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
|
||||
commit: () => Effect.void,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,42 @@ describe("resource", () => {
|
|||
})
|
||||
})
|
||||
|
||||
test("falls back to local logging when OTLP initialization fails", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-observability-test-"))
|
||||
await using _ = {
|
||||
async [Symbol.asyncDispose]() {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
"--eval",
|
||||
`
|
||||
import { Effect } from "effect"
|
||||
import { Observability } from "./src/observability.ts"
|
||||
await Effect.void.pipe(Effect.provide(Observability.layer), Effect.scoped, Effect.runPromise)
|
||||
`,
|
||||
],
|
||||
{
|
||||
cwd: path.join(import.meta.dir, "../.."),
|
||||
env: {
|
||||
...process.env,
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "://invalid",
|
||||
XDG_CACHE_HOME: path.join(dir, "cache"),
|
||||
XDG_CONFIG_HOME: path.join(dir, "config"),
|
||||
XDG_DATA_HOME: path.join(dir, "data"),
|
||||
XDG_STATE_HOME: path.join(dir, "state"),
|
||||
},
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
},
|
||||
)
|
||||
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
|
||||
|
||||
expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
})
|
||||
|
||||
test("file logger appends concurrent runs with a run on every line", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-"))
|
||||
await using _ = {
|
||||
|
|
|
|||
45
packages/core/test/event-logger.test.ts
Normal file
45
packages/core/test/event-logger.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Logger } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventLogger } from "@opencode-ai/core/event-logger"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Catalog } from "@opencode-ai/schema/catalog"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
|
||||
const UnlistedUpdated = EventV2.ephemeral({ type: "test.updated", schema: {} })
|
||||
|
||||
describe("EventLogger", () => {
|
||||
test("logs explicitly listed updated events", async () => {
|
||||
const output = new Array<ReturnType<typeof Logger.formatStructured.log>>()
|
||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
||||
output.push(entry)
|
||||
})
|
||||
|
||||
await Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(Agent.Event.Updated, {})
|
||||
yield* events.publish(Catalog.Event.Updated, {})
|
||||
yield* events.publish(Command.Event.Updated, {})
|
||||
yield* events.publish(Config.Event.Updated, {})
|
||||
yield* events.publish(McpEvent.StatusChanged, { server: "example" })
|
||||
yield* events.publish(UnlistedUpdated, {})
|
||||
}).pipe(
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, EventLogger.node]))),
|
||||
Effect.provide(Logger.layer([logger])),
|
||||
Effect.scoped,
|
||||
Effect.runPromise,
|
||||
)
|
||||
|
||||
expect(output.map((entry) => entry.message)).toEqual([
|
||||
["event", { event: expect.objectContaining({ type: "agent.updated" }) }],
|
||||
["event", { event: expect.objectContaining({ type: "catalog.updated" }) }],
|
||||
["event", { event: expect.objectContaining({ type: "command.updated" }) }],
|
||||
["event", { event: expect.objectContaining({ type: "config.updated" }) }],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
|
|||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
|
|
@ -22,14 +24,14 @@ const locationLayer = Layer.succeed(
|
|||
location({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") }),
|
||||
),
|
||||
)
|
||||
const Message = EventV2.define({
|
||||
const Message = EventV2.ephemeral({
|
||||
type: "test.message",
|
||||
schema: {
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
|
||||
const SyncMessage = EventV2.define({
|
||||
const SyncMessage = EventV2.durable({
|
||||
type: "test.sync",
|
||||
durable: {
|
||||
version: 1,
|
||||
|
|
@ -41,7 +43,7 @@ const SyncMessage = EventV2.define({
|
|||
},
|
||||
})
|
||||
|
||||
const SyncSent = EventV2.define({
|
||||
const SyncSent = EventV2.durable({
|
||||
type: "test.sent",
|
||||
durable: {
|
||||
version: 1,
|
||||
|
|
@ -53,14 +55,14 @@ const SyncSent = EventV2.define({
|
|||
},
|
||||
})
|
||||
|
||||
const GlobalMessage = EventV2.define({
|
||||
const GlobalMessage = EventV2.ephemeral({
|
||||
type: "test.global",
|
||||
schema: {
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
|
||||
const VersionedMessage = EventV2.define({
|
||||
const VersionedMessage = EventV2.durable({
|
||||
type: "test.versioned",
|
||||
durable: {
|
||||
version: 2,
|
||||
|
|
@ -129,12 +131,12 @@ describe("EventV2", () => {
|
|||
|
||||
it.effect("selects the latest durable definition independent of declaration order", () =>
|
||||
Effect.sync(() => {
|
||||
const latest = EventV2.define({
|
||||
const latest = EventV2.durable({
|
||||
type: "test.out-of-order",
|
||||
durable: { version: 2, aggregate: "id" },
|
||||
schema: { id: Schema.String },
|
||||
})
|
||||
const historical = EventV2.define({
|
||||
const historical = EventV2.durable({
|
||||
type: "test.out-of-order",
|
||||
durable: { version: 1, aggregate: "id" },
|
||||
schema: { id: Schema.String },
|
||||
|
|
@ -329,8 +331,8 @@ describe("EventV2", () => {
|
|||
const events = yield* EventV2.Service
|
||||
const consuming = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const slowStream = yield* EventV2.liveBounded(events, 1)
|
||||
const fastStream = yield* EventV2.liveBounded(events, 8)
|
||||
const slowStream = yield* EventV2.liveBounded(events, { capacity: 1 })
|
||||
const fastStream = yield* EventV2.liveBounded(events, { capacity: 8 })
|
||||
const slow = yield* slowStream.pipe(
|
||||
Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))),
|
||||
Effect.forkScoped,
|
||||
|
|
@ -355,6 +357,20 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("filters internal events before they enter a bounded server stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const stream = yield* EventV2.liveBounded(events, { capacity: 1, accept: EventManifest.isServer })
|
||||
const received = yield* stream.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
|
||||
yield* events.publish(McpEvent.ToolsChanged, { server: "one" })
|
||||
yield* events.publish(McpEvent.ToolsChanged, { server: "two" })
|
||||
const published = yield* events.publish(McpEvent.StatusChanged, { server: "example" })
|
||||
|
||||
expect(Array.from(yield* Fiber.join(received))).toEqual([published])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves observer interruption", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -554,6 +570,7 @@ describe("EventV2", () => {
|
|||
|
||||
yield* events.replay({
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
|
|
@ -573,6 +590,7 @@ describe("EventV2", () => {
|
|||
|
||||
yield* events.replay({
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
|
|
@ -609,6 +627,7 @@ describe("EventV2", () => {
|
|||
const exit = yield* events
|
||||
.replay({
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID: envelopeAggregateID,
|
||||
|
|
@ -642,6 +661,7 @@ describe("EventV2", () => {
|
|||
|
||||
yield* events.replay({
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
|
|
@ -650,6 +670,7 @@ describe("EventV2", () => {
|
|||
const exit = yield* events
|
||||
.replay({
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 5,
|
||||
aggregateID,
|
||||
|
|
@ -674,13 +695,14 @@ describe("EventV2", () => {
|
|||
|
||||
yield* events.replay({
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(SessionEvent.ContextUpdated.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
data: { sessionID: aggregateID, messageID: "msg_context", timestamp: 0, text: "context" },
|
||||
data: { sessionID: aggregateID, text: "context" },
|
||||
})
|
||||
|
||||
expect(received[0]?.data.timestamp).toEqual(DateTime.makeUnsafe(0))
|
||||
expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0))
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -690,6 +712,7 @@ describe("EventV2", () => {
|
|||
const exit = yield* events
|
||||
.replay({
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: "unknown.event.1",
|
||||
seq: 0,
|
||||
aggregateID: EventV2.ID.create(),
|
||||
|
|
@ -708,6 +731,7 @@ describe("EventV2", () => {
|
|||
const source = yield* events.replayAll([
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
|
|
@ -715,6 +739,7 @@ describe("EventV2", () => {
|
|||
},
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID,
|
||||
|
|
@ -735,6 +760,7 @@ describe("EventV2", () => {
|
|||
const one = yield* events.replayAll([
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
|
|
@ -742,6 +768,7 @@ describe("EventV2", () => {
|
|||
},
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID,
|
||||
|
|
@ -751,6 +778,7 @@ describe("EventV2", () => {
|
|||
const two = yield* events.replayAll([
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 2,
|
||||
aggregateID,
|
||||
|
|
@ -758,6 +786,7 @@ describe("EventV2", () => {
|
|||
},
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 3,
|
||||
aggregateID,
|
||||
|
|
@ -793,6 +822,7 @@ describe("EventV2", () => {
|
|||
yield* events.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID,
|
||||
|
|
@ -812,6 +842,7 @@ describe("EventV2", () => {
|
|||
const id = EventV2.ID.create()
|
||||
const replayed = {
|
||||
id,
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
|
|
@ -833,6 +864,7 @@ describe("EventV2", () => {
|
|||
const published = yield* events.publish(DurableMessage, durableData(aggregateID, "owned"))
|
||||
const replayed = {
|
||||
id: published.id,
|
||||
created: published.created,
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: published.durable!.seq,
|
||||
aggregateID,
|
||||
|
|
@ -867,6 +899,7 @@ describe("EventV2", () => {
|
|||
yield* events.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
|
|
@ -895,6 +928,7 @@ describe("EventV2", () => {
|
|||
yield* events.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID,
|
||||
|
|
@ -905,6 +939,7 @@ describe("EventV2", () => {
|
|||
yield* events.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 2,
|
||||
aggregateID,
|
||||
|
|
@ -937,6 +972,7 @@ describe("EventV2", () => {
|
|||
yield* events.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
|
|
@ -949,6 +985,7 @@ describe("EventV2", () => {
|
|||
.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID,
|
||||
|
|
@ -970,6 +1007,7 @@ describe("EventV2", () => {
|
|||
yield* events.listen((event) => Effect.sync(() => received.push(event)))
|
||||
const replayed = {
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
|
|
@ -990,6 +1028,7 @@ describe("EventV2", () => {
|
|||
const aggregateID = Session.ID.create()
|
||||
const replayed = {
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
|
|
@ -1014,6 +1053,7 @@ describe("EventV2", () => {
|
|||
const id = EventV2.ID.create()
|
||||
yield* events.replay({
|
||||
id,
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
|
|
@ -1023,6 +1063,7 @@ describe("EventV2", () => {
|
|||
const exit = yield* events
|
||||
.replay({
|
||||
id,
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID,
|
||||
|
|
@ -1045,6 +1086,7 @@ describe("EventV2", () => {
|
|||
yield* events.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
|
|
@ -1055,6 +1097,7 @@ describe("EventV2", () => {
|
|||
yield* events.replay(
|
||||
{
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID,
|
||||
|
|
@ -1116,6 +1159,7 @@ describe("EventV2", () => {
|
|||
|
||||
yield* events.replay({
|
||||
id: EventV2.ID.create(),
|
||||
created: DateTime.makeUnsafe(0),
|
||||
type: EventV2.versionedType(DurableMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { Ignore } from "@opencode-ai/core/filesystem/ignore"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
|
||||
test("match nested and non-nested", () => {
|
||||
expect(Ignore.match("node_modules/index.js")).toBe(true)
|
||||
|
|
@ -8,3 +10,30 @@ test("match nested and non-nested", () => {
|
|||
expect(Ignore.match("node_modules/bar")).toBe(true)
|
||||
expect(Ignore.match("node_modules/bar/")).toBe(true)
|
||||
})
|
||||
|
||||
test("parcel patterns ignore built-in folders at any depth", async () => {
|
||||
let ignoreGlobs: string[] = []
|
||||
const watcher = createWrapper({
|
||||
subscribe: async (
|
||||
_directory: string,
|
||||
_callback: (...args: unknown[]) => unknown,
|
||||
options: { ignoreGlobs?: string[] },
|
||||
) => {
|
||||
ignoreGlobs = options.ignoreGlobs ?? []
|
||||
},
|
||||
})
|
||||
await watcher.subscribe("/tmp/project", () => {}, { ignore: Ignore.PATTERNS })
|
||||
const patterns = ignoreGlobs.map((source) => new RegExp(source))
|
||||
|
||||
for (const path of [
|
||||
"nested/node_modules",
|
||||
"nested/node_modules/package/index.js",
|
||||
"nested/.git",
|
||||
"nested/.git/HEAD",
|
||||
"nested/dist",
|
||||
"nested/dist/index.js",
|
||||
]) {
|
||||
expect(patterns.some((pattern) => pattern.test(path))).toBe(true)
|
||||
}
|
||||
expect(patterns.some((pattern) => pattern.test("nested/src/index.ts"))).toBe(false)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@ import { $ } from "bun"
|
|||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
|
|
@ -34,7 +36,7 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
|
|||
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
||||
)
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(Watcher.node, [
|
||||
AppNodeBuilder.build(LocationWatcher.node, [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
|
|
@ -66,7 +68,7 @@ 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(
|
||||
const fiber = yield* events.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (!check(event.data)) return Effect.void
|
||||
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
|
||||
|
|
@ -136,7 +138,29 @@ function ready(directory: string) {
|
|||
})
|
||||
}
|
||||
|
||||
describeWatcher("Watcher", () => {
|
||||
describeWatcher("LocationWatcher", () => {
|
||||
it.live("limits file watches to the exact target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const target = path.join(directory, "opencode.json")
|
||||
const sibling = path.join(directory, "other.json")
|
||||
const update = yield* watcher
|
||||
.subscribe({ path: target, type: "file" })
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* fs.writeFileString(sibling, "sibling")
|
||||
const writes = yield* Effect.suspend(() => fs.writeFileString(target, `target-${Math.random()}`)).pipe(
|
||||
Effect.repeat(Schedule.spaced("10 millis")),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(writes)))
|
||||
|
||||
expect(event.valueOrUndefined?.path).toBe(target)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("publishes root create, update, and delete events", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
|
|
@ -175,6 +199,24 @@ describeWatcher("Watcher", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("ignores dependency, VCS, and build directories at any depth", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const afs = yield* FSUtil.Service
|
||||
yield* ready(directory)
|
||||
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
|
||||
const files = roots.map((root) => path.join(root, "package", "index.js"))
|
||||
yield* noUpdate(
|
||||
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
|
||||
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("cleanup stops publishing events", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
|
|||
40
packages/core/test/fixture/mcp-output-schema.ts
Normal file
40
packages/core/test/fixture/mcp-output-schema.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
const server = new Server({ name: "output-schema", version: "1.0.0" }, { capabilities: { tools: {} } })
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
|
||||
Promise.resolve(
|
||||
params?.cursor === "page-2"
|
||||
? {
|
||||
tools: [
|
||||
{
|
||||
name: "second",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { value: { type: "number" } },
|
||||
required: ["value"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
tools: [
|
||||
{
|
||||
name: "first",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { value: { type: "string" } },
|
||||
required: ["value"],
|
||||
},
|
||||
},
|
||||
],
|
||||
nextCursor: "page-2",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
await server.connect(new StdioServerTransport())
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit } from "effect"
|
||||
import { Deferred, Effect, Exit, Fiber } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
|
@ -19,6 +19,27 @@ const input = {
|
|||
} satisfies Form.CreateInput
|
||||
|
||||
describe("Form", () => {
|
||||
it.effect("returns a terminal cancelled state from ask", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
const events = yield* EventV2.Service
|
||||
const created = yield* Deferred.make<Form.Info>()
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
event.type === Form.Event.Created.type
|
||||
? Deferred.succeed(created, (event.data as { readonly form: Form.Info }).form).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
|
||||
const form = yield* Deferred.await(created)
|
||||
|
||||
yield* service.cancel(form.id)
|
||||
|
||||
expect(yield* Fiber.join(fiber)).toEqual({ status: "cancelled" })
|
||||
expect(yield* service.state(form.id)).toEqual({ status: "cancelled" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("supports the temporary global mcp elicitation owner", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
|
|
@ -92,6 +113,79 @@ describe("Form", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("requires every when condition to match and treats empty when as active", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
const created = yield* service.create({
|
||||
sessionID: "global",
|
||||
mode: "form",
|
||||
fields: [
|
||||
{ key: "a", type: "boolean" },
|
||||
{ key: "b", type: "boolean" },
|
||||
{
|
||||
key: "x",
|
||||
type: "string",
|
||||
required: true,
|
||||
when: [
|
||||
{ key: "a", op: "eq", value: true },
|
||||
{ key: "b", op: "eq", value: true },
|
||||
],
|
||||
},
|
||||
{ key: "z", type: "string", required: true, when: [] },
|
||||
],
|
||||
})
|
||||
|
||||
const missingX = yield* service.reply({ id: created.id, answer: { a: true, b: true, z: "ok" } }).pipe(Effect.flip)
|
||||
expect(missingX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" }))
|
||||
|
||||
const inactiveX = yield* service
|
||||
.reply({ id: created.id, answer: { a: true, b: false, x: "nope", z: "ok" } })
|
||||
.pipe(Effect.flip)
|
||||
expect(inactiveX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: x" }))
|
||||
|
||||
const missingZ = yield* service.reply({ id: created.id, answer: { a: true, b: false } }).pipe(Effect.flip)
|
||||
expect(missingZ).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" }))
|
||||
|
||||
yield* service.reply({ id: created.id, answer: { a: true, b: false, z: "ok" } })
|
||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { a: true, b: false, z: "ok" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("evaluates neq against multiselect answers as non-inclusion", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
const options = [
|
||||
{ value: "go", label: "Go" },
|
||||
{ value: "ts", label: "TypeScript" },
|
||||
]
|
||||
const created = yield* service.create({
|
||||
sessionID: "global",
|
||||
mode: "form",
|
||||
fields: [
|
||||
{ key: "langs", type: "multiselect", options },
|
||||
{ key: "note", type: "string", required: true, when: [{ key: "langs", op: "neq", value: "go" }] },
|
||||
],
|
||||
})
|
||||
|
||||
const missing = yield* service.reply({ id: created.id, answer: { langs: ["ts"] } }).pipe(Effect.flip)
|
||||
expect(missing).toEqual(
|
||||
new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: note" }),
|
||||
)
|
||||
|
||||
// an answered-but-empty multiselect also satisfies neq
|
||||
const missingEmpty = yield* service.reply({ id: created.id, answer: { langs: [] } }).pipe(Effect.flip)
|
||||
expect(missingEmpty).toEqual(
|
||||
new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: note" }),
|
||||
)
|
||||
|
||||
const inactive = yield* service.reply({ id: created.id, answer: { langs: ["go"], note: "x" } }).pipe(Effect.flip)
|
||||
expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" }))
|
||||
|
||||
yield* service.reply({ id: created.id, answer: { langs: ["go"] } })
|
||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { langs: ["go"] } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats unanswered when references as false and cascades inactivity", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import { AgentV2 } from "@opencode-ai/core/agent"
|
|||
import type { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { Effect } from "effect"
|
||||
import { Tools } from "@opencode-ai/core/tool/tools"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, type Scope } from "effect"
|
||||
|
||||
export const toolIdentity = {
|
||||
agent: AgentV2.ID.make("build"),
|
||||
|
|
@ -34,6 +36,29 @@ export function waitForTool(
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a core tool plugin's tools against the real registry without booting the
|
||||
* full plugin host. Only the tool domain is live; focused tool tests exercise
|
||||
* registration, materialization, and settlement through the same path production uses.
|
||||
*/
|
||||
export const registerToolPlugin = <R>(plugin: {
|
||||
readonly id: string
|
||||
readonly effect: (context: PluginContext) => Effect.Effect<void, never, R>
|
||||
}): Effect.Effect<void, never, R | Tools.Service | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const context: Pick<PluginContext, "tool"> = {
|
||||
tool: {
|
||||
register: tools.register,
|
||||
execute: {
|
||||
before: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
after: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
},
|
||||
},
|
||||
}
|
||||
yield* plugin.effect(context as PluginContext)
|
||||
})
|
||||
|
||||
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
|
||||
registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input)))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Equal, Hash, Schema } from "effect"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Context, DateTime, Effect, Equal, Hash, Schema, Stream } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
|
|
@ -10,6 +12,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
|
|
@ -27,6 +30,124 @@ import { ToolRegistry } from "../src/tool/registry"
|
|||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))
|
||||
|
||||
describe("LocationServiceMap", () => {
|
||||
it.live("applies ordered plugin config operations during boot", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(dir.path, "opencode.json"), JSON.stringify({ plugins: ["-*", "opencode.agent"] })),
|
||||
)
|
||||
const plugins = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
yield* (yield* PluginSupervisor.Service).ready
|
||||
return yield* plugins.list()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
|
||||
),
|
||||
)
|
||||
|
||||
expect(plugins.map((plugin) => plugin.id)).toEqual([Plugin.ID.make("opencode.agent")])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads the plugin generation after config updates", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir.path, "opencode.json")
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
|
||||
yield* Effect.gen(function* () {
|
||||
const registry = yield* PluginV2.Service
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.ready
|
||||
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.command")) break
|
||||
yield* Effect.sleep("20 millis")
|
||||
}
|
||||
|
||||
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.command"])
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts")],
|
||||
}),
|
||||
),
|
||||
)
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((yield* registry.list()).length === 0) break
|
||||
yield* Effect.sleep("20 millis")
|
||||
}
|
||||
expect(yield* registry.list()).toEqual([])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.agent")) break
|
||||
yield* Effect.sleep("20 millis")
|
||||
}
|
||||
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("routes located events only to their location", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
|
||||
).pipe(
|
||||
Effect.flatMap(([first, second]) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const events = yield* EventV2.Service
|
||||
const firstRef = Location.Ref.make({ directory: AbsolutePath.make(first.path) })
|
||||
const secondRef = Location.Ref.make({ directory: AbsolutePath.make(second.path) })
|
||||
const firstContext = yield* locations.contextEffect(firstRef)
|
||||
const secondContext = yield* locations.contextEffect(secondRef)
|
||||
const received = { first: 0, second: 0 }
|
||||
yield* events.subscribe(Config.Event.Updated).pipe(
|
||||
Stream.runForEach(() => Effect.sync(() => received.first++)),
|
||||
Effect.provideContext(firstContext),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* events.subscribe(Config.Event.Updated).pipe(
|
||||
Stream.runForEach(() => Effect.sync(() => received.second++)),
|
||||
Effect.provideContext(secondContext),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
yield* events.publish(Config.Event.Updated, {}, { location: firstRef })
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
expect(received).toEqual({ first: 1, second: 0 })
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reuses cached services for constructed and decoded location refs", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
@ -51,31 +172,38 @@ describe("LocationServiceMap", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("isolates location state while sharing location policy with catalog", () =>
|
||||
it.live("isolates catalog state by location", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
|
||||
).pipe(
|
||||
Effect.flatMap(([blocked, allowed]) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(blocked.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "test" }] },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const update = (directory: string) =>
|
||||
const update = (directory: string, providerID: ProviderV2.ID) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Reference.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "glob")
|
||||
yield* waitForTool(registry, "shell")
|
||||
yield* waitForTool(registry, "subagent")
|
||||
// Tool plugins register during the forked PluginSupervisor boot; wait for
|
||||
// every expected tool rather than relying on batch ordering.
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"question",
|
||||
"read",
|
||||
"shell",
|
||||
"skill",
|
||||
"subagent",
|
||||
"todowrite",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
"write",
|
||||
],
|
||||
(name) => waitForTool(registry, name),
|
||||
)
|
||||
return {
|
||||
providers: yield* catalog.provider.all(),
|
||||
tools: yield* toolDefinitions(registry),
|
||||
|
|
@ -87,8 +215,11 @@ describe("LocationServiceMap", () => {
|
|||
),
|
||||
)
|
||||
|
||||
const blockedState = yield* update(blocked.path)
|
||||
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false)
|
||||
const blockedID = ProviderV2.ID.make("blocked-location")
|
||||
const allowedID = ProviderV2.ID.make("allowed-location")
|
||||
const blockedState = yield* update(blocked.path, blockedID)
|
||||
expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true)
|
||||
expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
|
||||
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"edit",
|
||||
"glob",
|
||||
|
|
@ -103,8 +234,9 @@ describe("LocationServiceMap", () => {
|
|||
"websearch",
|
||||
"write",
|
||||
])
|
||||
const allowedState = yield* update(allowed.path)
|
||||
expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true)
|
||||
const allowedState = yield* update(allowed.path, allowedID)
|
||||
expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true)
|
||||
expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
|
||||
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"edit",
|
||||
"glob",
|
||||
|
|
@ -246,7 +378,7 @@ describe("LocationServiceMap", () => {
|
|||
})
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
yield* plugins.add(PluginV2.ID.make(reviewer.id), reviewer.effect)
|
||||
yield* plugins.activate([{ plugin: reviewer }])
|
||||
|
||||
expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({
|
||||
description: "Reviews code",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID }
|
|||
const projectLayer = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
directories: () => Effect.succeed([]),
|
||||
resolve: () =>
|
||||
Effect.succeed({
|
||||
|
|
|
|||
255
packages/core/test/mcp.test.ts
Normal file
255
packages/core/test/mcp.test.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import path from "node:path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { ConfigMCP } from "@opencode-ai/core/config/mcp"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { MCPClient } from "@opencode-ai/core/mcp/client"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
|
||||
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
|
||||
let calls = 0
|
||||
|
||||
const mcp = Layer.mock(MCP.Service, {
|
||||
tools: () =>
|
||||
Effect.succeed([
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("demo"),
|
||||
name: "search",
|
||||
description: "Search",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { ok: { type: "boolean" } },
|
||||
required: ["ok"],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
callTool: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls += 1
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
structured: { ok: true },
|
||||
content: [],
|
||||
})
|
||||
}),
|
||||
})
|
||||
const permissions = Layer.mock(PermissionV2.Service, {
|
||||
assert: (input) =>
|
||||
Effect.gen(function* () {
|
||||
if (!assertion) return yield* Effect.die("Permission test is not initialized")
|
||||
yield* Deferred.succeed(assertion, input)
|
||||
yield* decision
|
||||
}),
|
||||
})
|
||||
const events = Layer.mock(EventV2.Service, { subscribe: () => Stream.never })
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, McpTool.node]), [
|
||||
[MCP.node, mcp],
|
||||
[PermissionV2.node, permissions],
|
||||
[EventV2.node, events],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
]),
|
||||
)
|
||||
|
||||
describe("MCP errors", () => {
|
||||
test("expose useful messages", () => {
|
||||
expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
|
||||
expect(new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message).toBe(
|
||||
"failed",
|
||||
)
|
||||
expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
|
||||
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
|
||||
})
|
||||
})
|
||||
|
||||
test("MCP tool names match V1 sanitization", () => {
|
||||
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
|
||||
})
|
||||
|
||||
test("preserves output schema validation across paginated tool discovery", async () => {
|
||||
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
|
||||
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
|
||||
Promise.resolve(
|
||||
params?.cursor === "page-2"
|
||||
? {
|
||||
tools: [
|
||||
{
|
||||
name: "second",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { value: { type: "number" } },
|
||||
required: ["value"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
tools: [
|
||||
{
|
||||
name: "first",
|
||||
inputSchema: { type: "object" },
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { value: { type: "string" } },
|
||||
required: ["value"],
|
||||
},
|
||||
},
|
||||
],
|
||||
nextCursor: "page-2",
|
||||
},
|
||||
),
|
||||
)
|
||||
server.setRequestHandler(CallToolRequestSchema, ({ params }) =>
|
||||
Promise.resolve({
|
||||
content: [],
|
||||
structuredContent: { value: params.name === "first" ? 42 : 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
const client = new Client({ name: "pagination-test", version: "1.0.0" })
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
|
||||
|
||||
try {
|
||||
const first = await client.listTools()
|
||||
const second = await client.listTools({ cursor: first.nextCursor })
|
||||
expect([...first.tools, ...second.tools].map((tool) => tool.name)).toEqual(["first", "second"])
|
||||
await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
|
||||
"Structured content does not match the tool's output schema",
|
||||
)
|
||||
} finally {
|
||||
await Promise.all([client.close(), server.close()])
|
||||
}
|
||||
})
|
||||
|
||||
test("retains output schemas across paginated MCP discovery", async () => {
|
||||
const tools = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* MCPClient.connect(
|
||||
"pagination",
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
}),
|
||||
import.meta.dir,
|
||||
)
|
||||
return yield* connection.tools()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(tools.map((tool) => ({ name: tool.name, outputSchema: tool.outputSchema }))).toEqual([
|
||||
{
|
||||
name: "first",
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { value: { type: "string" } },
|
||||
required: ["value"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "second",
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { value: { type: "number" } },
|
||||
required: ["value"],
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute")
|
||||
|
||||
expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{ ok: boolean }>")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("waits for permission before calling an MCP tool", () =>
|
||||
Effect.gen(function* () {
|
||||
calls = 0
|
||||
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
|
||||
const permission = yield* Deferred.make<void>()
|
||||
decision = Deferred.await(permission)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
|
||||
const fiber = yield* settleTool(registry, {
|
||||
sessionID: SessionV2.ID.make("ses_mcp_permission"),
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call_mcp_permission",
|
||||
name: "execute",
|
||||
input: { code: "return await tools.demo.search({})" },
|
||||
},
|
||||
}).pipe(Effect.forkScoped)
|
||||
expect(yield* Deferred.await(assertion)).toEqual({
|
||||
action: "demo_search",
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: {},
|
||||
sessionID: SessionV2.ID.make("ses_mcp_permission"),
|
||||
agent: toolIdentity.agent,
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: toolIdentity.assistantMessageID,
|
||||
callID: "call_mcp_permission",
|
||||
},
|
||||
})
|
||||
expect(calls).toBe(0)
|
||||
|
||||
yield* Deferred.succeed(permission, undefined)
|
||||
yield* Fiber.join(fiber)
|
||||
expect(calls).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not call MCP when permission is rejected", () =>
|
||||
Effect.gen(function* () {
|
||||
calls = 0
|
||||
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
|
||||
decision = Effect.fail(new PermissionV2.RejectedError())
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
|
||||
const settlement = yield* settleTool(registry, {
|
||||
sessionID: SessionV2.ID.make("ses_mcp_rejected"),
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call_mcp_rejected",
|
||||
name: "execute",
|
||||
input: { code: "return await tools.demo.search({})" },
|
||||
},
|
||||
})
|
||||
expect(settlement.result).toEqual({ type: "text", value: "Unable to execute demo_search" })
|
||||
expect(settlement.output?.structured).toEqual({
|
||||
toolCalls: [{ tool: "demo.search", status: "error" }],
|
||||
error: true,
|
||||
})
|
||||
expect(calls).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
|
@ -149,6 +149,22 @@ describe("ModelsDev Service", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("allows models.dev entries without temperature metadata", () =>
|
||||
Effect.sync(() => {
|
||||
const result = Schema.decodeUnknownSync(ModelsDev.Model)({
|
||||
id: "no-temperature-model",
|
||||
name: "No Temperature Model",
|
||||
release_date: "2026-01-01",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
tool_call: true,
|
||||
limit: { context: 128000, output: 8192 },
|
||||
})
|
||||
|
||||
expect(result.temperature).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() returns providers from disk when cache file exists", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* writeCache(fixture)
|
||||
|
|
|
|||
47
packages/core/test/newtype.test.ts
Normal file
47
packages/core/test/newtype.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Newtype } from "../src/schema"
|
||||
|
||||
class UserID extends Newtype<UserID>()("Test.UserID", Schema.NonEmptyString) {}
|
||||
class ProjectID extends Newtype<ProjectID>()("Test.ProjectID", Schema.NonEmptyString) {}
|
||||
class Port extends Newtype<Port>()("Test.Port", Schema.FiniteFromString) {}
|
||||
|
||||
const User = Schema.Struct({ id: UserID })
|
||||
|
||||
test("constructs nominal values from the underlying type", () => {
|
||||
const id = UserID.make("user-1")
|
||||
const acceptUserID = (_id: UserID) => undefined
|
||||
|
||||
expect(String(id)).toBe("user-1")
|
||||
acceptUserID(id)
|
||||
|
||||
if (false) {
|
||||
// @ts-expect-error distinct newtypes are not interchangeable
|
||||
acceptUserID(ProjectID.make("project-1"))
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves constructor validation", () => {
|
||||
expect(() => UserID.make("")).toThrow()
|
||||
})
|
||||
|
||||
test("decodes and encodes as a schema", async () => {
|
||||
const decoded = await Effect.runPromise(Schema.decodeUnknownEffect(User)({ id: "user-1" }))
|
||||
const encoded = await Effect.runPromise(Schema.encodeEffect(User)(decoded))
|
||||
|
||||
expect(String(decoded.id)).toBe("user-1")
|
||||
expect(encoded).toEqual({ id: "user-1" })
|
||||
})
|
||||
|
||||
test("preserves the underlying schema validation", async () => {
|
||||
const result = await Effect.runPromise(Schema.decodeUnknownEffect(UserID)("").pipe(Effect.result))
|
||||
expect(result._tag).toBe("Failure")
|
||||
})
|
||||
|
||||
test("preserves transformed encoded and decoded representations", async () => {
|
||||
const decoded = await Effect.runPromise(Schema.decodeUnknownEffect(Port)("8080"))
|
||||
const encoded = await Effect.runPromise(Schema.encodeEffect(Port)(decoded))
|
||||
|
||||
expect(Number(decoded)).toBe(8080)
|
||||
expect(encoded).toBe("8080")
|
||||
})
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Schema } from "effect"
|
||||
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
|
|
@ -13,40 +17,36 @@ import { PluginTestLayer } from "./plugin/fixture"
|
|||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
|
||||
|
||||
describe("PluginV2", () => {
|
||||
it.effect("waits for a plugin and returns immediately once active", () =>
|
||||
it.live("exposes public events through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const id = PluginV2.ID.make("waited")
|
||||
const waiting = yield* plugins.wait(id).pipe(Effect.forkChild)
|
||||
const events = yield* EventV2.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const received = yield* host.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
yield* plugins.add(id, () => Effect.void)
|
||||
yield* Fiber.join(waiting)
|
||||
yield* plugins.wait(id)
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
|
||||
expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("propagates plugin activation defects to waiters", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const id = PluginV2.ID.make("failed")
|
||||
const waiting = yield* plugins.wait(id).pipe(Effect.exit, Effect.forkChild)
|
||||
|
||||
const added = yield* plugins.add(id, () => Effect.die("boom")).pipe(Effect.exit)
|
||||
const pending = yield* Fiber.join(waiting)
|
||||
const later = yield* plugins.wait(id).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(added)).toBe(true)
|
||||
expect(Exit.isFailure(pending)).toBe(true)
|
||||
expect(Exit.isFailure(later)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds, replaces, and removes plugins", () =>
|
||||
it.effect("skips identical generations and replaces changed plugin versions", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
let description = "first"
|
||||
const updated = yield* events
|
||||
.subscribe(Plugin.Event.Updated)
|
||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
const managed = () =>
|
||||
define({
|
||||
|
|
@ -61,19 +61,102 @@ describe("PluginV2", () => {
|
|||
.pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
yield* plugins.add(PluginV2.ID.make("managed"), managed().effect)
|
||||
yield* plugins.activate([{ plugin: managed() }])
|
||||
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
|
||||
|
||||
description = "second"
|
||||
yield* plugins.add(PluginV2.ID.make("managed"), managed().effect)
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
|
||||
yield* plugins.activate([{ plugin: managed() }])
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
|
||||
|
||||
yield* plugins.remove(PluginV2.ID.make("managed"))
|
||||
yield* plugins.activate([{ plugin: managed(), version: "next" }])
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
|
||||
expect(yield* Fiber.join(updated)).toHaveLength(2)
|
||||
|
||||
yield* plugins.activate([])
|
||||
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects duplicate IDs before replacing the active generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const active = Plugin.ID.make("active")
|
||||
const duplicate = "duplicate"
|
||||
yield* plugins.activate([{ plugin: { id: active, effect: () => Effect.void } }])
|
||||
|
||||
const result = yield* plugins
|
||||
.activate([
|
||||
{ plugin: { id: duplicate, effect: () => Effect.void } },
|
||||
{ plugin: { id: duplicate, effect: () => Effect.void } },
|
||||
])
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
expect(yield* plugins.list()).toEqual([{ id: active }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries the same generation after materialization fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
let fail = true
|
||||
const plugin = define({
|
||||
id: "retry",
|
||||
effect: (ctx) =>
|
||||
ctx.agent
|
||||
.transform(() => {
|
||||
if (fail) throw new Error("materialization failed")
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
expect(Exit.isFailure(yield* plugins.activate([{ plugin }]).pipe(Effect.exit))).toBe(true)
|
||||
fail = false
|
||||
yield* plugins.activate([{ plugin }])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("retry") }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("closes the previous generation in reverse order", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const closed: string[] = []
|
||||
yield* plugins.activate(
|
||||
["first", "second"].map((id) => ({
|
||||
plugin: {
|
||||
id,
|
||||
effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
yield* plugins.activate([])
|
||||
|
||||
expect(closed).toEqual(["second", "first"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("isolates plugins from ambient services", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
let visible = true
|
||||
const plugin = define({
|
||||
id: "isolated",
|
||||
effect: () =>
|
||||
Effect.serviceOption(Secret).pipe(
|
||||
Effect.tap((secret) => Effect.sync(() => (visible = secret._tag === "Some"))),
|
||||
Effect.asVoid,
|
||||
),
|
||||
})
|
||||
|
||||
yield* plugins.activate([{ plugin }]).pipe(Effect.provideService(Secret, "secret"))
|
||||
|
||||
expect(visible).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers location tools through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
|
|
@ -93,18 +176,51 @@ describe("PluginV2", () => {
|
|||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
|
||||
yield* plugins.activate([{ plugin }])
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
|
||||
"plugin_tool",
|
||||
)
|
||||
|
||||
yield* plugins.remove(PluginV2.ID.make(plugin.id))
|
||||
yield* plugins.activate([])
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain(
|
||||
"plugin_tool",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("groups tool names and defers registrations from direct exposure", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const tool = (description: string) =>
|
||||
Tool.make({
|
||||
description,
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
})
|
||||
const plugin = define({
|
||||
id: "grouped-tools",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.tool.register({ plain: tool("Plain") }).pipe(Effect.orDie)
|
||||
yield* ctx.tool.register({ "look/up": tool("Lookup") }, { group: "context 7" }).pipe(Effect.orDie)
|
||||
yield* ctx.tool
|
||||
.register({ search: tool("Search") }, { group: "context 7", deferred: true })
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* plugins.activate([{ plugin }])
|
||||
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toEqual([
|
||||
"plain",
|
||||
"context_7_look_up",
|
||||
"execute",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fires before/after tool hooks with mutable events around settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
|
|
@ -147,7 +263,7 @@ describe("PluginV2", () => {
|
|||
}),
|
||||
})
|
||||
|
||||
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
|
||||
yield* plugins.activate([{ plugin }])
|
||||
|
||||
const materialized = yield* registry.materialize({ model: testModel })
|
||||
const settlement = yield* materialized.settle({
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ describe("CommandPlugin.Plugin", () => {
|
|||
const command = yield* CommandV2.Service
|
||||
yield* CommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: { transform: command.transform, reload: command.reload },
|
||||
command: {
|
||||
list: () => Effect.die("unused command.list"),
|
||||
transform: command.transform,
|
||||
reload: command.reload,
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
|
|
|
|||
7
packages/core/test/plugin/fixtures/failing-plugin.ts
Normal file
7
packages/core/test/plugin/fixtures/failing-plugin.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default define({
|
||||
id: "failing-plugin",
|
||||
effect: () => Effect.die("plugin failed"),
|
||||
})
|
||||
29
packages/core/test/plugin/fixtures/variant-source-plugin.ts
Normal file
29
packages/core/test/plugin/fixtures/variant-source-plugin.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export default define({
|
||||
id: "variant-source",
|
||||
effect: (ctx) =>
|
||||
ctx.catalog
|
||||
.transform((catalog) => {
|
||||
catalog.provider.update("configured", (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
|
||||
})
|
||||
catalog.model.update("configured", "glm-5.2", (model) => {
|
||||
model.api = {
|
||||
id: "glm-5.2",
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
}
|
||||
model.variants = [
|
||||
{
|
||||
id: "high",
|
||||
settings: {},
|
||||
headers: { custom: "true" },
|
||||
body: {},
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
|
|
@ -6,7 +6,7 @@ import { Integration } from "@opencode-ai/core/integration"
|
|||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
|
||||
type Overrides = Partial<Omit<PluginContext, "options">>
|
||||
|
||||
|
|
@ -23,14 +23,33 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
language: () => Effect.die("unused aisdk.language"),
|
||||
},
|
||||
catalog: overrides.catalog ?? {
|
||||
provider: {
|
||||
list: () => Effect.die("unused catalog.provider.list"),
|
||||
get: () => Effect.die("unused catalog.provider.get"),
|
||||
},
|
||||
model: {
|
||||
list: () => Effect.die("unused catalog.model.list"),
|
||||
default: () => Effect.die("unused catalog.model.default"),
|
||||
},
|
||||
transform: () => Effect.die("unused catalog.transform"),
|
||||
reload: () => Effect.die("unused catalog.reload"),
|
||||
},
|
||||
command: overrides.command ?? {
|
||||
list: () => Effect.die("unused command.list"),
|
||||
transform: () => Effect.die("unused command.transform"),
|
||||
reload: () => Effect.die("unused command.reload"),
|
||||
},
|
||||
event: overrides.event ?? {
|
||||
subscribe: () => Stream.empty,
|
||||
},
|
||||
integration: overrides.integration ?? {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
connectKey: () => Effect.die("unused integration.connectKey"),
|
||||
connectOauth: () => Effect.die("unused integration.connectOauth"),
|
||||
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
|
||||
attemptComplete: () => Effect.die("unused integration.attemptComplete"),
|
||||
attemptCancel: () => Effect.die("unused integration.attemptCancel"),
|
||||
transform: () => Effect.die("unused integration.transform"),
|
||||
reload: () => Effect.die("unused integration.reload"),
|
||||
connection: {
|
||||
|
|
@ -39,14 +58,15 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
},
|
||||
},
|
||||
plugin: overrides.plugin ?? {
|
||||
add: () => Effect.die("unused plugin.add"),
|
||||
remove: () => Effect.die("unused plugin.remove"),
|
||||
list: () => Effect.die("unused plugin.list"),
|
||||
},
|
||||
reference: overrides.reference ?? {
|
||||
list: () => Effect.die("unused reference.list"),
|
||||
transform: () => Effect.die("unused reference.transform"),
|
||||
reload: () => Effect.die("unused reference.reload"),
|
||||
},
|
||||
skill: overrides.skill ?? {
|
||||
list: () => Effect.die("unused skill.list"),
|
||||
transform: () => Effect.die("unused skill.transform"),
|
||||
reload: () => Effect.die("unused skill.reload"),
|
||||
},
|
||||
|
|
@ -97,6 +117,14 @@ export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
|
|||
|
||||
export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] {
|
||||
return {
|
||||
provider: {
|
||||
list: () => Effect.die("unused catalog.provider.list"),
|
||||
get: () => Effect.die("unused catalog.provider.get"),
|
||||
},
|
||||
model: {
|
||||
list: () => Effect.die("unused catalog.model.list"),
|
||||
default: () => Effect.die("unused catalog.model.default"),
|
||||
},
|
||||
reload: catalog.reload,
|
||||
transform: (callback) =>
|
||||
catalog.transform((draft) =>
|
||||
|
|
@ -161,6 +189,13 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
|
|||
|
||||
export function integrationHost(integration: Integration.Interface): PluginContext["integration"] {
|
||||
return {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
connectKey: () => Effect.die("unused integration.connectKey"),
|
||||
connectOauth: () => Effect.die("unused integration.connectOauth"),
|
||||
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
|
||||
attemptComplete: () => Effect.die("unused integration.attemptComplete"),
|
||||
attemptCancel: () => Effect.die("unused integration.attemptCancel"),
|
||||
reload: integration.reload,
|
||||
connection: {
|
||||
active: (id) => integration.connection.active(Integration.ID.make(id)),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,35 @@ import { PluginTestLayer } from "./fixture"
|
|||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
describe("fromPromise", () => {
|
||||
it.effect("forwards standard client reads", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const seen: string[] = []
|
||||
const promisePlugin = define({
|
||||
id: "promise-client-reads",
|
||||
setup: async (ctx) => {
|
||||
const results = await Promise.all([
|
||||
ctx.agent.list(),
|
||||
ctx.catalog.provider.list(),
|
||||
ctx.catalog.model.list(),
|
||||
ctx.command.list(),
|
||||
ctx.integration.list(),
|
||||
ctx.plugin.list(),
|
||||
ctx.reference.list(),
|
||||
ctx.skill.list(),
|
||||
])
|
||||
seen.push(...results.map((result) => result.location.directory))
|
||||
},
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||
|
||||
expect(seen).toHaveLength(8)
|
||||
expect(new Set(seen).size).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads a promise plugin and registers a transform hook", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const addPlugin = Effect.fn(function* () {
|
|||
|
||||
describe("KiloPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("kilo"))),
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.kilo")),
|
||||
)
|
||||
|
||||
it.effect("applies legacy referer headers only to kilo", () =>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const addPlugin = Effect.fn(function* () {
|
|||
|
||||
describe("LLMGatewayPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmgateway"))),
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.llmgateway")),
|
||||
)
|
||||
|
||||
it.effect("applies legacy referer headers only to enabled llmgateway", () =>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const addPlugin = Effect.fn(function* () {
|
|||
|
||||
describe("NvidiaPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("nvidia"))),
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.nvidia")),
|
||||
)
|
||||
|
||||
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const addPlugin = Effect.fn(function* () {
|
|||
|
||||
describe("OpenRouterPlugin", () => {
|
||||
it.effect("is registered so legacy OpenRouter behavior can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("openrouter"))),
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.openrouter")),
|
||||
)
|
||||
|
||||
it.effect("applies legacy referer headers only to openrouter", () =>
|
||||
|
|
|
|||
|
|
@ -43,9 +43,11 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
|||
describe("SnowflakeCortexPlugin", () => {
|
||||
it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex"))
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.snowflake-cortex")
|
||||
const ids = ProviderPlugins.map((p) => p.id)
|
||||
expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible"))
|
||||
expect(ids.indexOf("opencode.provider.snowflake-cortex")).toBeLessThan(
|
||||
ids.indexOf("opencode.provider.openai-compatible"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ function required<T>(value: T | undefined): T {
|
|||
|
||||
describe("ZenmuxPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("zenmux"))),
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.zenmux")),
|
||||
)
|
||||
|
||||
it.effect("applies the exact legacy Zenmux headers", () =>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,15 @@ describe("SkillPlugin.Plugin", () => {
|
|||
it.effect("registers built-in skills", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* SkillV2.Service
|
||||
yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })).pipe(
|
||||
yield* SkillPlugin.Plugin.effect(
|
||||
host({
|
||||
skill: {
|
||||
list: () => Effect.die("unused skill.list"),
|
||||
transform: skill.transform,
|
||||
reload: skill.reload,
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })),
|
||||
Effect.provideService(
|
||||
Location.Service,
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(Policy.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
|
||||
],
|
||||
]),
|
||||
)
|
||||
|
||||
describe("Policy", () => {
|
||||
it.effect("returns the caller's fallback when no statement matches", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
|
||||
expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("evaluates wildcard provider rules in written order", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
yield* policy.load([
|
||||
new Policy.Info({
|
||||
effect: "deny",
|
||||
action: "provider.*",
|
||||
resource: "*",
|
||||
}),
|
||||
new Policy.Info({
|
||||
effect: "allow",
|
||||
action: "provider.use",
|
||||
resource: "anthropic",
|
||||
}),
|
||||
])
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
|
||||
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("matches action and resource independently", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
yield* policy.load([
|
||||
new Policy.Info({
|
||||
effect: "deny",
|
||||
action: "provider.*",
|
||||
resource: "company-*",
|
||||
}),
|
||||
])
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "company-stable", "allow")).toBe("deny")
|
||||
expect(yield* policy.evaluate("plugin.load", "company-stable", "allow")).toBe("allow")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the last matching loaded statement", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
yield* policy.load([
|
||||
new Policy.Info({
|
||||
effect: "allow",
|
||||
action: "provider.use",
|
||||
resource: "openai",
|
||||
}),
|
||||
new Policy.Info({
|
||||
effect: "deny",
|
||||
action: "provider.use",
|
||||
resource: "openai",
|
||||
}),
|
||||
])
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -2,16 +2,72 @@ import { afterAll, describe, expect } from "bun:test"
|
|||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
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(AppNodeBuilder.build(ProjectV2.node))
|
||||
const it = testEffect(
|
||||
Layer.merge(AppNodeBuilder.build(ProjectV2.node), AppNodeBuilder.build(Database.node)),
|
||||
)
|
||||
|
||||
describe("ProjectV2.list", () => {
|
||||
it.effect("returns complete projects ordered by recent update", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const project = yield* ProjectV2.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values([
|
||||
{
|
||||
id: ProjectV2.ID.make("older"),
|
||||
worktree: abs("/older"),
|
||||
vcs: "git",
|
||||
name: "Older",
|
||||
icon_color: "#000000",
|
||||
commands: { start: "bun dev" },
|
||||
sandboxes: [abs("/older/sandbox")],
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
},
|
||||
{
|
||||
id: ProjectV2.ID.make("newer"),
|
||||
worktree: abs("/newer"),
|
||||
sandboxes: [],
|
||||
time_created: 2,
|
||||
time_updated: 2,
|
||||
time_initialized: 3,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
|
||||
expect(yield* project.list()).toEqual([
|
||||
{
|
||||
id: ProjectV2.ID.make("newer"),
|
||||
worktree: abs("/newer"),
|
||||
time: { created: 2, updated: 2, initialized: 3 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: ProjectV2.ID.make("older"),
|
||||
worktree: abs("/older"),
|
||||
vcs: "git",
|
||||
name: "Older",
|
||||
icon: { color: "#000000" },
|
||||
commands: { start: "bun dev" },
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [abs("/older/sandbox")],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const globalConfig = await tmpdir()
|
||||
afterAll(() => globalConfig[Symbol.asyncDispose]())
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const model = Model.make({
|
|||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
|
|
@ -85,17 +86,13 @@ describe("SessionV2.compact", () => {
|
|||
const prompt = Prompt.make({ text: "Please compact this session history." })
|
||||
yield* events.publish(SessionEvent.PromptAdmitted, {
|
||||
sessionID: created.id,
|
||||
messageID,
|
||||
timestamp: DateTime.makeUnsafe(0),
|
||||
inputID: messageID,
|
||||
prompt,
|
||||
delivery: "steer",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
yield* events.publish(SessionEvent.PromptPromoted, {
|
||||
sessionID: created.id,
|
||||
messageID,
|
||||
timestamp: DateTime.makeUnsafe(0),
|
||||
prompt,
|
||||
delivery: "steer",
|
||||
inputID: messageID,
|
||||
})
|
||||
|
||||
yield* session.compact({ sessionID: created.id })
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@ 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 { 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 { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
|
|
@ -31,6 +31,7 @@ import { tmpdir } from "./fixture/tmpdir"
|
|||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
|
|
@ -62,6 +63,13 @@ const assertCreateInputTypes = (session: SessionV2.Interface) => {
|
|||
}
|
||||
void assertCreateInputTypes
|
||||
|
||||
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("SessionV2.create", () => {
|
||||
it.effect("creates a fresh projected session when the ID is omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -190,8 +198,6 @@ describe("SessionV2.create", () => {
|
|||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID: parent.id,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
text: "parent note",
|
||||
})
|
||||
|
||||
|
|
@ -208,7 +214,7 @@ describe("SessionV2.create", () => {
|
|||
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
|
||||
expect(history).toHaveLength(1)
|
||||
expect(history[0]).toMatchObject({
|
||||
type: "session.next.forked",
|
||||
type: "session.forked",
|
||||
durable: { seq: 0 },
|
||||
data: { sessionID: forked.id, parentID: parent.id },
|
||||
})
|
||||
|
|
@ -260,7 +266,7 @@ describe("SessionV2.create", () => {
|
|||
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
|
||||
expect(context).toMatchObject([{ text: "First" }])
|
||||
expect(context[0]?.id).not.toBe(first.id)
|
||||
expect(history[0]).toMatchObject({ data: { messageID: second.id } })
|
||||
expect(history[0]).toMatchObject({ data: { from: second.id } })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -373,8 +379,8 @@ describe("SessionV2.create", () => {
|
|||
expect(
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
|
||||
).toMatchObject([
|
||||
{ durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } },
|
||||
{ durable: { seq: 2 }, type: "session.next.prompted" },
|
||||
{ durable: { seq: 1 }, type: "session.prompt.admitted", data: { prompt: { text: "Hello" } } },
|
||||
{ durable: { seq: 2 }, type: "session.prompt.promoted" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
|
@ -399,6 +405,7 @@ describe("SessionV2.create", () => {
|
|||
.all()
|
||||
.pipe(Effect.orDie)).map((event) => ({
|
||||
id: event.id,
|
||||
created: DateTime.makeUnsafe(event.created),
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
|
|
@ -459,7 +466,7 @@ describe("SessionV2.create", () => {
|
|||
).toEqual([
|
||||
[0, EventV2.versionedType(SessionV1.Event.Created.type, 1)],
|
||||
[1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)],
|
||||
[2, EventV2.versionedType(SessionEvent.Prompted.type, 1)],
|
||||
[2, EventV2.versionedType(SessionEvent.PromptPromoted.type, 1)],
|
||||
])
|
||||
}).pipe(Effect.provide(Layer.fresh(targetLayer)))
|
||||
}),
|
||||
|
|
@ -476,20 +483,43 @@ describe("SessionV2.create", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
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")),
|
||||
)
|
||||
it.live("runs a shell command and projects the started/ended shell message", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
})
|
||||
|
||||
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
|
||||
}),
|
||||
yield* session.shell({ sessionID: created.id, command: "echo hello" })
|
||||
|
||||
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
|
||||
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
|
||||
expect(shell).toMatchObject({ type: "shell", shell: { command: "echo hello", status: "exited", exit: 0 } })
|
||||
expect(shell?.output?.output).toContain("hello")
|
||||
expect(shell?.output?.truncated).toBe(false)
|
||||
expect(shell?.time.completed).toBeDefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("still emits shell ended for a failing command", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
})
|
||||
|
||||
yield* session.shell({ sessionID: created.id, command: "false" })
|
||||
|
||||
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
|
||||
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
|
||||
expect(shell).toMatchObject({ type: "shell", shell: { command: "false", status: "exited" } })
|
||||
expect(shell?.shell.exit).not.toBe(0)
|
||||
expect(shell?.time.completed).toBeDefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("switches the selected agent through the durable Session event", () =>
|
||||
|
|
@ -502,7 +532,7 @@ describe("SessionV2.create", () => {
|
|||
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
||||
expect(
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }])
|
||||
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -533,9 +563,9 @@ describe("SessionV2.create", () => {
|
|||
yield* session.switchModel({ sessionID: created.id, model })
|
||||
|
||||
expect(yield* session.get(created.id)).toMatchObject({ model })
|
||||
expect(
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ type: "session.next.model.switched", data: { model } }])
|
||||
const events = Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect))
|
||||
expect(events).toMatchObject([{ type: "session.model.selected" }])
|
||||
expect(events[0]?.data).toEqual({ sessionID: created.id, model })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,12 +33,29 @@ import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
|||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { settleTool, testModel } from "./lib/tool"
|
||||
import { registerToolPlugin, settleTool, testModel } from "./lib/tool"
|
||||
|
||||
const readToolNode = makeLocationNode({
|
||||
name: "test/read-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)),
|
||||
deps: [
|
||||
ToolRegistry.toolsNode,
|
||||
ReadToolFileSystem.node,
|
||||
LocationMutation.node,
|
||||
Image.node,
|
||||
PermissionV2.node,
|
||||
SessionInstructions.node,
|
||||
FSUtil.node,
|
||||
Location.node,
|
||||
],
|
||||
})
|
||||
|
||||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
|
|
@ -69,7 +86,7 @@ const testLayer = AppNodeBuilder.build(
|
|||
FSUtil.node,
|
||||
LocationMutation.node,
|
||||
ReadToolFileSystem.node,
|
||||
ReadTool.node,
|
||||
readToolNode,
|
||||
ToolRegistry.node,
|
||||
ToolRegistry.toolsNode,
|
||||
ToolHooks.node,
|
||||
|
|
@ -117,8 +134,6 @@ const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) =>
|
|||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
text: `Instructions from: ${paths[0]}\nprior`,
|
||||
description: `Loaded ${paths[0]}`,
|
||||
metadata: { instruction: { paths } },
|
||||
|
|
@ -156,7 +171,9 @@ describe("SessionInstructions", () => {
|
|||
expect(firstInjected[0]!.text).toBe(
|
||||
`Instructions from: ${deepPath}\ndeep-instructions\n\nInstructions from: ${subPath}\nsub-instructions`,
|
||||
)
|
||||
expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`)
|
||||
expect(firstInjected[0]!.description).toBe(
|
||||
`Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`,
|
||||
)
|
||||
// The synthetic's metadata carries the durable dedup ledger.
|
||||
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } })
|
||||
expect(firstInjected[0]!.text).not.toContain("root-instructions")
|
||||
|
|
@ -192,47 +209,49 @@ describe("SessionInstructions", () => {
|
|||
// Seed the durable history with a prior synthetic that already claims sub's AGENTS.md
|
||||
// via the instruction metadata ledger.
|
||||
yield* seedSynthetic(sessionID, [subPath])
|
||||
expect((yield* synthetics(sessionID))).toHaveLength(1)
|
||||
expect(yield* synthetics(sessionID)).toHaveLength(1)
|
||||
|
||||
yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
|
||||
|
||||
// The durable claim on the prior synthetic prevents re-injection; no new synthetic.
|
||||
expect((yield* synthetics(sessionID))).toHaveLength(1)
|
||||
expect(yield* synthetics(sessionID)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const dir = location.directory
|
||||
const rootPath = path.resolve(dir, "AGENTS.md")
|
||||
const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md")
|
||||
yield* mkdir(path.resolve(dir, "packages", "foo"))
|
||||
yield* writeAgents(rootPath, "root-instructions")
|
||||
yield* writeAgents(pkgPath, "pkg-instructions")
|
||||
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content"))
|
||||
it.effect(
|
||||
"discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const dir = location.directory
|
||||
const rootPath = path.resolve(dir, "AGENTS.md")
|
||||
const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md")
|
||||
yield* mkdir(path.resolve(dir, "packages", "foo"))
|
||||
yield* writeAgents(rootPath, "root-instructions")
|
||||
yield* writeAgents(pkgPath, "pkg-instructions")
|
||||
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content"))
|
||||
|
||||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||
|
||||
// Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
|
||||
// the Location root (already supplied by the core/instructions baseline).
|
||||
yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
|
||||
// Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
|
||||
// the Location root (already supplied by the core/instructions baseline).
|
||||
yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
|
||||
|
||||
const firstInjected = yield* synthetics(sessionID)
|
||||
expect(firstInjected).toHaveLength(1)
|
||||
expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`)
|
||||
expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`)
|
||||
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } })
|
||||
expect(firstInjected[0]!.text).not.toContain("root-instructions")
|
||||
const firstInjected = yield* synthetics(sessionID)
|
||||
expect(firstInjected).toHaveLength(1)
|
||||
expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`)
|
||||
expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`)
|
||||
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } })
|
||||
expect(firstInjected[0]!.text).not.toContain("root-instructions")
|
||||
|
||||
// A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is
|
||||
// already injected for this session, so nothing new is emitted.
|
||||
yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
|
||||
// A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is
|
||||
// already injected for this session, so nothing new is emitted.
|
||||
yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
|
||||
|
||||
expect((yield* synthetics(sessionID))).toHaveLength(1)
|
||||
}),
|
||||
expect(yield* synthetics(sessionID)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("listing the Location root directory injects no instructions", () =>
|
||||
|
|
@ -253,7 +272,7 @@ describe("SessionInstructions", () => {
|
|||
// dropped by the dirname filter, and up() only walks upward so nested dirs are unseen.
|
||||
yield* settleTool(registry, readCall(sessionID, "call-root-list", "."))
|
||||
|
||||
expect((yield* synthetics(sessionID))).toHaveLength(0)
|
||||
expect(yield* synthetics(sessionID)).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { testEffect } from "./lib/effect"
|
|||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
|
|
@ -40,14 +41,14 @@ describe("SessionV2.log", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: created.id, title: "renamed" })
|
||||
yield* session.rename({ sessionID: created.id, title: "session.renamed" })
|
||||
|
||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
|
||||
const watermark = (yield* events.sequences([created.id])).get(created.id)
|
||||
|
||||
// Session creation commits a non-public durable event, so the marker's
|
||||
// seq covers more of the aggregate than the public events emitted.
|
||||
expect(items.map((item) => item.type)).toEqual(["session.next.renamed", "log.synced"])
|
||||
expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"])
|
||||
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark })
|
||||
}),
|
||||
)
|
||||
|
|
@ -64,7 +65,7 @@ describe("SessionV2.log", () => {
|
|||
yield* session.rename({ sessionID: created.id, title: "renamed live" })
|
||||
|
||||
const items = Array.from(yield* Fiber.join(fiber))
|
||||
expect(items.map((item) => item.type)).toEqual(["log.synced", "session.next.renamed"])
|
||||
expect(items.map((item) => item.type)).toEqual(["log.synced", "session.renamed"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -78,7 +79,7 @@ describe("SessionV2.log", () => {
|
|||
|
||||
it.effect("reads across undecodable gaps in aggregate order and marks the true log position", () =>
|
||||
Effect.gen(function* () {
|
||||
const GapEvent = EventV2.define({
|
||||
const GapEvent = EventV2.durable({
|
||||
type: "test.session.log.gap",
|
||||
durable: { aggregate: "sessionID", version: 1 },
|
||||
schema: { sessionID: SessionV2.ID, value: Schema.String },
|
||||
|
|
@ -137,7 +138,7 @@ describe("SessionV2 watermarks", () => {
|
|||
const events = yield* EventV2.Service
|
||||
const first = yield* session.create({ location })
|
||||
const second = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: first.id, title: "renamed" })
|
||||
yield* session.rename({ sessionID: first.id, title: "session.renamed" })
|
||||
|
||||
const page = yield* session.list()
|
||||
const sequences = yield* events.sequences([first.id, second.id])
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ 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 { Shell } from "@opencode-ai/schema/shell"
|
||||
import {
|
||||
SessionContextCheckpointTable,
|
||||
SessionInputTable,
|
||||
|
|
@ -33,6 +34,7 @@ const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.no
|
|||
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 previousModel = { ...model, variant: ModelV2.VariantID.make("medium") }
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
||||
|
||||
const assistantRow = (
|
||||
|
|
@ -79,7 +81,6 @@ describe("SessionProjector", () => {
|
|||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] },
|
||||
})
|
||||
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({
|
||||
|
|
@ -87,17 +88,15 @@ describe("SessionProjector", () => {
|
|||
snapshot: "tree",
|
||||
files: [],
|
||||
})
|
||||
yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID, timestamp: DateTime.makeUnsafe(2) })
|
||||
yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID })
|
||||
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull()
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(3),
|
||||
revert: { messageID: boundary, files: [] },
|
||||
})
|
||||
yield* events.publish(SessionEvent.RevertEvent.Committed, {
|
||||
sessionID,
|
||||
messageID: boundary,
|
||||
timestamp: DateTime.makeUnsafe(4),
|
||||
})
|
||||
expect(
|
||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
||||
|
|
@ -131,37 +130,29 @@ describe("SessionProjector", () => {
|
|||
|
||||
yield* events.publish(SessionEvent.PromptAdmitted, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.make("msg_first"),
|
||||
timestamp: created,
|
||||
inputID: SessionMessage.ID.make("msg_first"),
|
||||
prompt: Prompt.make({ text: "first" }),
|
||||
delivery: "steer",
|
||||
})
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
SessionEvent.PromptPromoted,
|
||||
{
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.make("msg_first"),
|
||||
timestamp: created,
|
||||
prompt: Prompt.make({ text: "first" }),
|
||||
delivery: "steer",
|
||||
inputID: SessionMessage.ID.make("msg_first"),
|
||||
},
|
||||
{ id: EventV2.ID.make("evt_z") },
|
||||
)
|
||||
yield* events.publish(SessionEvent.PromptAdmitted, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.make("msg_second"),
|
||||
timestamp: created,
|
||||
inputID: SessionMessage.ID.make("msg_second"),
|
||||
prompt: Prompt.make({ text: "second" }),
|
||||
delivery: "steer",
|
||||
})
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
SessionEvent.PromptPromoted,
|
||||
{
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.make("msg_second"),
|
||||
timestamp: created,
|
||||
prompt: Prompt.make({ text: "second" }),
|
||||
delivery: "steer",
|
||||
inputID: SessionMessage.ID.make("msg_second"),
|
||||
},
|
||||
{ id: EventV2.ID.make("evt_a") },
|
||||
)
|
||||
|
|
@ -190,7 +181,7 @@ describe("SessionProjector", () => {
|
|||
}).pipe(Effect.provide(sessionsLayer)),
|
||||
)
|
||||
|
||||
it.effect("marks an inbox row promoted with the Prompted event sequence", () =>
|
||||
it.effect("marks an inbox row promoted with the PromptPromoted event sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
|
|
@ -220,12 +211,9 @@ describe("SessionProjector", () => {
|
|||
})
|
||||
if (!admitted) return yield* Effect.die("Prompt admission failed")
|
||||
|
||||
const event = yield* events.publish(SessionEvent.Prompted, {
|
||||
const event = yield* events.publish(SessionEvent.PromptPromoted, {
|
||||
sessionID,
|
||||
timestamp: admitted.timeCreated,
|
||||
messageID: id,
|
||||
prompt: Prompt.make({ text: "promote me" }),
|
||||
delivery: "steer",
|
||||
inputID: id,
|
||||
})
|
||||
|
||||
expect(
|
||||
|
|
@ -251,54 +239,59 @@ describe("SessionProjector", () => {
|
|||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
model: previousModel,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
yield* events.publish(SessionEvent.AgentSelected, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: created,
|
||||
agent: "build",
|
||||
})
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
yield* events.publish(SessionEvent.ModelSelected, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: created,
|
||||
model,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: created,
|
||||
text: "synthetic context",
|
||||
metadata: { source: "projector-test" },
|
||||
})
|
||||
yield* events.publish(SessionEvent.Shell.Started, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: created,
|
||||
callID: "shell-1",
|
||||
command: "pwd",
|
||||
shell: Shell.Info.make({
|
||||
id: Shell.ID.make("sh_projector"),
|
||||
status: "running",
|
||||
command: "pwd",
|
||||
cwd: "/project",
|
||||
shell: "/bin/sh",
|
||||
file: "/tmp/sh_projector.out",
|
||||
metadata: {},
|
||||
time: { started: 0 },
|
||||
}),
|
||||
})
|
||||
yield* events.publish(SessionEvent.Shell.Ended, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
callID: "shell-1",
|
||||
output: "/project",
|
||||
shell: Shell.Info.make({
|
||||
id: Shell.ID.make("sh_projector"),
|
||||
status: "exited",
|
||||
command: "pwd",
|
||||
cwd: "/project",
|
||||
shell: "/bin/sh",
|
||||
file: "/tmp/sh_projector.out",
|
||||
exit: 0,
|
||||
metadata: {},
|
||||
time: { started: 0, completed: 1 },
|
||||
}),
|
||||
output: { output: "/project", cursor: 8, size: 8, truncated: false },
|
||||
})
|
||||
const compactionID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
messageID: compactionID,
|
||||
timestamp: created,
|
||||
reason: "manual",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID,
|
||||
messageID: compactionID,
|
||||
timestamp: created,
|
||||
text: "partial",
|
||||
})
|
||||
expect(
|
||||
|
|
@ -319,8 +312,6 @@ describe("SessionProjector", () => {
|
|||
).toEqual([])
|
||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
messageID: compactionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
reason: "manual",
|
||||
text: "summary",
|
||||
recent: "recent context",
|
||||
|
|
@ -348,9 +339,11 @@ describe("SessionProjector", () => {
|
|||
text: "synthetic context",
|
||||
metadata: { source: "projector-test" },
|
||||
})
|
||||
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
|
||||
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
||||
output: "/project",
|
||||
time: { completed: DateTime.makeUnsafe(1) },
|
||||
shell: { command: "pwd", status: "exited", exit: 0 },
|
||||
output: { output: "/project", truncated: false },
|
||||
time: { completed: DateTime.makeUnsafe(0) },
|
||||
})
|
||||
expect(messages.find((message) => message.type === "compaction")).toMatchObject({
|
||||
summary: "summary",
|
||||
|
|
@ -388,13 +381,20 @@ describe("SessionProjector", () => {
|
|||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const id = SessionMessage.ID.make("msg_creator_collision")
|
||||
const {
|
||||
id: _,
|
||||
type,
|
||||
...data
|
||||
} = encodeMessage({ id, sessionID, type: "synthetic", text: "existing", time: { created } })
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data })
|
||||
.run()
|
||||
|
||||
yield* events.publish(SessionEvent.Synthetic, { sessionID, messageID: id, timestamp: created, text: "keep me" })
|
||||
const exit = yield* events
|
||||
.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: id,
|
||||
timestamp: created,
|
||||
agent: "build",
|
||||
model,
|
||||
})
|
||||
|
|
@ -464,7 +464,6 @@ describe("SessionProjector", () => {
|
|||
const service = yield* EventV2.Service
|
||||
yield* service.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
|
|
@ -485,7 +484,7 @@ describe("SessionProjector", () => {
|
|||
expect(messages[1]).toMatchObject({
|
||||
type: "assistant",
|
||||
finish: "stop",
|
||||
time: { completed: DateTime.makeUnsafe(1) },
|
||||
time: { completed: DateTime.makeUnsafe(0) },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -526,7 +525,6 @@ describe("SessionProjector", () => {
|
|||
yield* service.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
|
||||
timestamp: DateTime.makeUnsafe(3),
|
||||
textID: "text-stale",
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -201,7 +201,6 @@ describe("SessionV2.prompt", () => {
|
|||
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
revert: { messageID: boundary.id, files: [] },
|
||||
})
|
||||
expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id)
|
||||
|
|
@ -257,16 +256,16 @@ describe("SessionV2.prompt", () => {
|
|||
const streamed = Array.from(yield* Fiber.join(fiber))
|
||||
|
||||
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
|
||||
[0, "session.next.prompt.admitted"],
|
||||
[1, "session.next.prompt.admitted"],
|
||||
[2, "session.next.prompted"],
|
||||
[3, "session.next.prompted"],
|
||||
[0, "session.prompt.admitted"],
|
||||
[1, "session.prompt.admitted"],
|
||||
[2, "session.prompt.promoted"],
|
||||
[3, "session.prompt.promoted"],
|
||||
])
|
||||
expect(
|
||||
Array.from(
|
||||
yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect),
|
||||
).map((event): [number | undefined, string] => [event.durable?.seq, event.type]),
|
||||
).toEqual([[1, "session.next.prompt.admitted"]])
|
||||
).toEqual([[1, "session.prompt.admitted"]])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -429,7 +428,7 @@ describe("SessionV2.prompt", () => {
|
|||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(yield* eventCount(EventV2.versionedType(SessionEvent.Prompted.type, 1))).toBe(1)
|
||||
expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptPromoted.type, 1))).toBe(1)
|
||||
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||
{ id: messageID, type: "user", text: "Promote once" },
|
||||
|
|
@ -467,6 +466,7 @@ describe("SessionV2.prompt", () => {
|
|||
yield* events.replayAll(
|
||||
recorded.map((event) => ({
|
||||
id: event.id,
|
||||
created: DateTime.makeUnsafe(event.created),
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
|
|
@ -514,13 +514,23 @@ describe("SessionV2.prompt", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
const { db } = yield* Database.Service
|
||||
const {
|
||||
id: _,
|
||||
type,
|
||||
...data
|
||||
} = encodeMessage({
|
||||
id: messageID,
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
type: "synthetic",
|
||||
text: "Existing history",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
})
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values({ id: messageID, session_id: sessionID, type, seq: 0, time_created: 0, data })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const failure = yield* session
|
||||
.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false })
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
|||
import { AgentAttachment, FileAttachment } 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 { Shell } from "@opencode-ai/schema/shell"
|
||||
import { DateTime } from "effect"
|
||||
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
|
|
@ -51,13 +52,13 @@ describe("toLLMMessages", () => {
|
|||
const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.AgentSwitched.make({
|
||||
SessionMessage.AgentSelected.make({
|
||||
id: id("agent"),
|
||||
type: "agent-switched",
|
||||
agent: "build",
|
||||
time: { created },
|
||||
}),
|
||||
SessionMessage.ModelSwitched.make({
|
||||
SessionMessage.ModelSelected.make({
|
||||
id: id("model"),
|
||||
type: "model-switched",
|
||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||
|
|
@ -87,9 +88,18 @@ describe("toLLMMessages", () => {
|
|||
SessionMessage.Shell.make({
|
||||
id: id("shell"),
|
||||
type: "shell",
|
||||
callID: "shell-1",
|
||||
command: "pwd",
|
||||
output: "/project",
|
||||
shell: Shell.Info.make({
|
||||
id: Shell.ID.make("sh_test"),
|
||||
status: "exited",
|
||||
command: "pwd",
|
||||
cwd: "/project",
|
||||
shell: "/bin/sh",
|
||||
file: "/tmp/sh_test.out",
|
||||
exit: 0,
|
||||
metadata: {},
|
||||
time: { started: 0, completed: 0 },
|
||||
}),
|
||||
output: { output: "/project", cursor: 8, size: 8, truncated: false },
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
SessionMessage.Compaction.make({
|
||||
|
|
@ -354,7 +364,7 @@ Recent work
|
|||
state: SessionMessage.ToolStateError.make({
|
||||
status: "error",
|
||||
input: { query: "Effect" },
|
||||
error: { type: "unknown", message: "Provider turn interrupted" },
|
||||
error: { type: "unknown", message: "Step interrupted" },
|
||||
content: [],
|
||||
structured: {},
|
||||
}),
|
||||
|
|
@ -362,7 +372,7 @@ Recent work
|
|||
}),
|
||||
],
|
||||
finish: "error",
|
||||
error: { type: "unknown", message: "Provider turn interrupted" },
|
||||
error: { type: "unknown", message: "Step interrupted" },
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
|
|
@ -386,7 +396,7 @@ Recent work
|
|||
result: {
|
||||
type: "error",
|
||||
value: {
|
||||
error: { type: "unknown", message: "Provider turn interrupted" },
|
||||
error: { type: "unknown", message: "Step interrupted" },
|
||||
content: [],
|
||||
structured: {},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ const execution = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
const sessionRunner = yield* SessionRunner.Service
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
|
||||
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
|
||||
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
|
||||
})
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
|
|
@ -193,12 +193,12 @@ describe("SessionRunnerLLM recorded", () => {
|
|||
.orderBy(EventTable.seq)
|
||||
.all()).map((event) => event.type),
|
||||
).toEqual([
|
||||
"session.next.prompt.admitted.1",
|
||||
"session.next.prompted.1",
|
||||
"session.next.step.started.1",
|
||||
"session.next.text.started.1",
|
||||
"session.next.text.ended.1",
|
||||
"session.next.step.ended.2",
|
||||
"session.prompt.admitted.1",
|
||||
"session.prompt.promoted.1",
|
||||
"session.step.started.1",
|
||||
"session.text.started.1",
|
||||
"session.text.ended.1",
|
||||
"session.step.ended.1",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ test("local tool success serializes media base64 once and reconstructs from stru
|
|||
await Effect.runPromise(publisher.publish(call))
|
||||
await Effect.runPromise(publisher.publish(result))
|
||||
|
||||
const success = published.find((event) => event.type === "session.next.tool.success.1")
|
||||
const success = published.find((event) => event.type === "session.tool.success.1")
|
||||
expect(success).toBeDefined()
|
||||
const serialized = JSON.stringify(success)
|
||||
expect(serialized.split(base64)).toHaveLength(2)
|
||||
|
|
@ -94,7 +94,7 @@ test("provider-executed success retains its compatibility result", async () => {
|
|||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
|
||||
const success = published.find((event) => event.type === "session.next.tool.success.1")
|
||||
const success = published.find((event) => event.type === "session.tool.success.1")
|
||||
expect(success?.data).toHaveProperty("result")
|
||||
})
|
||||
|
||||
|
|
@ -110,14 +110,13 @@ test("binary failure emits no success event", async () => {
|
|||
}),
|
||||
),
|
||||
)
|
||||
expect(published.some((event) => event.type === "session.next.tool.success.1")).toBe(false)
|
||||
expect(published.some((event) => event.type === "session.next.tool.failed.1")).toBe(true)
|
||||
expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false)
|
||||
expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
|
||||
})
|
||||
|
||||
test("old success event data containing result still decodes", () => {
|
||||
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
|
||||
sessionID,
|
||||
timestamp: Date.now(),
|
||||
assistantMessageID: SessionMessage.ID.create(),
|
||||
callID: "call-old",
|
||||
structured: { type: "media", mime: "image/png" },
|
||||
|
|
@ -133,6 +132,6 @@ test("step finish records settlement without publishing step ended", async () =>
|
|||
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" })))
|
||||
|
||||
expect(published.some((event) => event.type === "session.next.step.ended.2")).toBe(false)
|
||||
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
|
||||
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
|
|||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
|
|
@ -39,6 +39,7 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
|||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { QuestionTool } from "@opencode-ai/core/tool/question"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
|
|
@ -260,7 +261,7 @@ const execution = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
const sessionRunner = yield* SessionRunner.Service
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
|
||||
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
|
||||
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
|
||||
})
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
|
|
@ -276,7 +277,7 @@ const it = testEffect(
|
|||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
QuestionV2.node,
|
||||
Form.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
AgentV2.node,
|
||||
|
|
@ -423,6 +424,7 @@ const replaySessionProjection = (id: SessionV2.ID) =>
|
|||
yield* events.replayAll(
|
||||
recorded.map((event) => ({
|
||||
id: event.id,
|
||||
created: DateTime.makeUnsafe(event.created),
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
|
|
@ -574,7 +576,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
|||
)
|
||||
|
||||
const runner = yield* SessionRunner.Service
|
||||
const fiber = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
|
||||
const fiber = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamed)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
|
|
@ -582,7 +584,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
|||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "unknown", message: "Provider turn interrupted" },
|
||||
error: { type: "unknown", message: "Step interrupted" },
|
||||
content: [
|
||||
kind === "tool input"
|
||||
? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } }
|
||||
|
|
@ -740,7 +742,6 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
yield* events.publish(SessionEvent.Moved, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
|
||||
})
|
||||
expect(
|
||||
|
|
@ -848,7 +849,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, "session.next.context.updated.1"))
|
||||
.where(eq(EventTable.type, "session.context.updated.1"))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
).toHaveLength(1)
|
||||
|
|
@ -1010,10 +1011,8 @@ describe("SessionRunnerLLM", () => {
|
|||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
yield* events.publish(SessionEvent.AgentSelected, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: "reviewer",
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
|
|
@ -1039,10 +1038,8 @@ describe("SessionRunnerLLM", () => {
|
|||
if (switched) return Effect.void
|
||||
switched = true
|
||||
return events
|
||||
.publish(SessionEvent.AgentSwitched, {
|
||||
.publish(SessionEvent.AgentSelected, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: "reviewer",
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
|
|
@ -1069,10 +1066,8 @@ describe("SessionRunnerLLM", () => {
|
|||
if (switched) return Effect.void
|
||||
switched = true
|
||||
return events
|
||||
.publish(SessionEvent.ModelSwitched, {
|
||||
.publish(SessionEvent.ModelSelected, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
|
|
@ -1175,10 +1170,8 @@ describe("SessionRunnerLLM", () => {
|
|||
systemBaseline = "Changed context"
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
yield* events.publish(SessionEvent.ModelSelected, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
systemBaseline = "Replacement context"
|
||||
|
|
@ -1217,10 +1210,8 @@ describe("SessionRunnerLLM", () => {
|
|||
requests.length = 0
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
yield* events.publish(SessionEvent.ModelSelected, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
systemUnavailable = true
|
||||
|
|
@ -1252,14 +1243,10 @@ describe("SessionRunnerLLM", () => {
|
|||
const compactionID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
messageID: compactionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
reason: "manual",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
messageID: compactionID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
reason: "manual",
|
||||
text: "summary",
|
||||
recent: "",
|
||||
|
|
@ -1482,14 +1469,10 @@ describe("SessionRunnerLLM", () => {
|
|||
const compactionID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
messageID: compactionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
reason: "manual",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
messageID: compactionID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
reason: "manual",
|
||||
text: "summary",
|
||||
recent: "",
|
||||
|
|
@ -1686,10 +1669,8 @@ describe("SessionRunnerLLM", () => {
|
|||
toolExecutionsReady = 1
|
||||
const run = yield* Effect.forkChild(session.resume(sessionID))
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
yield* events.publish(SessionEvent.ModelSelected, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
systemBaseline = "Replacement context"
|
||||
|
|
@ -2403,27 +2384,23 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID,
|
||||
callID: "call-interrupted",
|
||||
name: "echo",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID,
|
||||
callID: "call-interrupted",
|
||||
text: '{"text":"stale"}',
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID,
|
||||
callID: "call-interrupted",
|
||||
tool: "echo",
|
||||
|
|
@ -2467,27 +2444,23 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID,
|
||||
callID: "call-hosted-interrupted",
|
||||
name: "web_search",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID,
|
||||
callID: "call-hosted-interrupted",
|
||||
text: '{"query":"stale"}',
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID,
|
||||
callID: "call-hosted-interrupted",
|
||||
tool: "web_search",
|
||||
|
|
@ -2527,13 +2500,11 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID,
|
||||
callID: "call-pending-interrupted",
|
||||
name: "echo",
|
||||
|
|
@ -2578,7 +2549,7 @@ describe("SessionRunnerLLM", () => {
|
|||
const events = yield* EventV2.Service
|
||||
const defect = new Error("fail after prompt promotion")
|
||||
let fail = true
|
||||
yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void))
|
||||
yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void))
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false })
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
|
|
@ -2603,7 +2574,9 @@ describe("SessionRunnerLLM", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.listen((event) =>
|
||||
event.type === SessionEvent.Prompted.type ? Effect.die("fail after prompt promotion commits") : Effect.void,
|
||||
event.type === SessionEvent.PromptPromoted.type
|
||||
? Effect.die("fail after prompt promotion commits")
|
||||
: Effect.void,
|
||||
)
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
|
|
@ -2850,19 +2823,17 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("interrupts runner continuation when a question is dismissed", () =>
|
||||
it.effect("interrupts runner continuation when a question is cancelled", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const questions = yield* QuestionV2.Service
|
||||
yield* registry.register({
|
||||
question: Tool.make({
|
||||
description: "Ask the user",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: (_, context) =>
|
||||
questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
|
||||
execute: () => Effect.die(new QuestionTool.CancelledError()),
|
||||
}),
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
|
||||
|
|
@ -2879,12 +2850,6 @@ describe("SessionRunnerLLM", () => {
|
|||
]
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
|
||||
let pending = yield* questions.list()
|
||||
while (pending.length === 0) {
|
||||
yield* Effect.yieldNow
|
||||
pending = yield* questions.list()
|
||||
}
|
||||
yield* questions.reject(pending[0]!.id)
|
||||
const exit = yield* Fiber.join(run)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
|
|
@ -3011,9 +2976,9 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Interrupt provider" },
|
||||
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Provider turn interrupted" } },
|
||||
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Step interrupted" } },
|
||||
])
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain("session.next.step.failed.2")
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain("session.step.failed.1")
|
||||
yield* session.interrupt(sessionID)
|
||||
}),
|
||||
)
|
||||
|
|
@ -3035,7 +3000,7 @@ describe("SessionRunnerLLM", () => {
|
|||
]
|
||||
|
||||
const runner = yield* SessionRunner.Service
|
||||
const run = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
|
||||
const run = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
yield* Fiber.interrupt(run)
|
||||
toolExecutionGate = undefined
|
||||
|
|
@ -3046,7 +3011,7 @@ describe("SessionRunnerLLM", () => {
|
|||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "unknown", message: "Provider turn interrupted" },
|
||||
error: { type: "unknown", message: "Step interrupted" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
|
|
@ -3057,8 +3022,8 @@ describe("SessionRunnerLLM", () => {
|
|||
},
|
||||
])
|
||||
const eventTypes = yield* recordedEventTypes(sessionID)
|
||||
expect(eventTypes).toContain("session.next.step.failed.2")
|
||||
expect(eventTypes).not.toContain("session.next.step.ended.2")
|
||||
expect(eventTypes).toContain("session.step.failed.1")
|
||||
expect(eventTypes).not.toContain("session.step.ended.1")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -3424,9 +3389,10 @@ describe("SessionRunnerLLM", () => {
|
|||
streamStarted = undefined
|
||||
response = [LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })]
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"Duplicate text start: text-1",
|
||||
)
|
||||
const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))
|
||||
expect(defect).toBeInstanceOf(Error)
|
||||
if (!(defect instanceof Error)) return
|
||||
expect(defect.message).toBe("Duplicate text start: text-1")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -3468,9 +3434,10 @@ describe("SessionRunnerLLM", () => {
|
|||
streamStarted = undefined
|
||||
response = [LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })]
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"Tool input delta before start: call-1",
|
||||
)
|
||||
const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))
|
||||
expect(defect).toBeInstanceOf(Error)
|
||||
if (!(defect instanceof Error)) return
|
||||
expect(defect.message).toBe("Tool input delta before start: call-1")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
70
packages/core/test/session-skill.test.ts
Normal file
70
packages/core/test/session-skill.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const projects = Layer.mock(ProjectV2.Service, {
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
})
|
||||
const skills = Layer.mock(SkillV2.Service, {
|
||||
list: () =>
|
||||
Effect.succeed([
|
||||
SkillV2.Info.make({
|
||||
name: "effect",
|
||||
description: "Effect guidance",
|
||||
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
|
||||
content: "Use Effect",
|
||||
}),
|
||||
]),
|
||||
})
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
() =>
|
||||
// The skill endpoint only needs the location-scoped Skill service.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
skills as unknown as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
|
||||
[
|
||||
[LocationServiceMap.node, locations],
|
||||
[ProjectV2.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("SessionV2.skill", () => {
|
||||
it.effect("projects the caller-supplied message ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* SessionV2.Service
|
||||
const session = yield* sessions.create({ location })
|
||||
const id = SessionMessage.ID.make("msg_caller_skill")
|
||||
|
||||
yield* sessions.skill({ id, sessionID: session.id, skill: "effect", resume: false })
|
||||
|
||||
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
|
||||
expect.objectContaining({ id, type: "skill", name: "effect", text: "Use Effect" }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -86,17 +86,13 @@ const prompt = (sessionID: SessionV2.ID, text: string) =>
|
|||
const messageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.PromptAdmitted, {
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: DateTime.makeUnsafe(0),
|
||||
inputID: messageID,
|
||||
prompt: Prompt.make({ text }),
|
||||
delivery: "steer",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
yield* events.publish(SessionEvent.PromptPromoted, {
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: DateTime.makeUnsafe(0),
|
||||
prompt: Prompt.make({ text }),
|
||||
delivery: "steer",
|
||||
inputID: messageID,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ describe("Tool.Progress", () => {
|
|||
yield* service.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp,
|
||||
agent: "build",
|
||||
model,
|
||||
})
|
||||
|
|
@ -69,14 +68,12 @@ describe("Tool.Progress", () => {
|
|||
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",
|
||||
|
|
@ -92,7 +89,6 @@ describe("Tool.Progress", () => {
|
|||
|
||||
yield* service.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID,
|
||||
timestamp,
|
||||
assistantMessageID,
|
||||
callID: "call-success",
|
||||
structured: { phase: "checkpoint" },
|
||||
|
|
@ -104,7 +100,6 @@ describe("Tool.Progress", () => {
|
|||
|
||||
const success = yield* service.publish(SessionEvent.Tool.Success, {
|
||||
sessionID,
|
||||
timestamp,
|
||||
assistantMessageID,
|
||||
callID: "call-success",
|
||||
structured: { phase: "done" },
|
||||
|
|
@ -118,7 +113,6 @@ describe("Tool.Progress", () => {
|
|||
yield* start("call-failed")
|
||||
yield* service.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID,
|
||||
timestamp,
|
||||
assistantMessageID,
|
||||
callID: "call-failed",
|
||||
structured: { phase: "checkpoint" },
|
||||
|
|
@ -126,7 +120,6 @@ describe("Tool.Progress", () => {
|
|||
})
|
||||
const failed = yield* service.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID,
|
||||
timestamp,
|
||||
assistantMessageID,
|
||||
callID: "call-failed",
|
||||
error: { type: "unknown", message: "boom" },
|
||||
|
|
|
|||
|
|
@ -22,14 +22,12 @@ import { FileSystem } from "@opencode-ai/schema/filesystem"
|
|||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { LLM } from "@opencode-ai/schema/llm"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { Reference } from "@opencode-ai/schema/reference"
|
||||
import { SessionTodo } from "@opencode-ai/schema/session-todo"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { AbsolutePath, DateTimeUtcFromMillis, optional, statics } from "@opencode-ai/schema/schema"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
|
||||
test("Core reuses the canonical shared schemas", async () => {
|
||||
const [
|
||||
|
|
@ -129,8 +127,6 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
[corePermission.Ruleset, Permission.Ruleset],
|
||||
[corePermissionV1.Event, PermissionV1.Event],
|
||||
[coreProjectCopy.Event, ProjectDirectories.Event],
|
||||
[PluginV2.ID, Plugin.ID],
|
||||
[PluginV2.Event, Plugin.Event],
|
||||
[corePty.Info, Pty.Info],
|
||||
[corePty.Event, Pty.Event],
|
||||
[coreProject.ID, Project.ID],
|
||||
|
|
@ -148,8 +144,8 @@ test("Core reuses the canonical shared schemas", async () => {
|
|||
[coreSessionInput.Admitted, SessionInput.Admitted],
|
||||
[coreSessionMessage.ID, SessionMessage.ID],
|
||||
[coreSessionMessage.UnknownError, SessionMessage.UnknownError],
|
||||
[coreSessionMessage.AgentSwitched, SessionMessage.AgentSwitched],
|
||||
[coreSessionMessage.ModelSwitched, SessionMessage.ModelSwitched],
|
||||
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
|
||||
[coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
|
||||
[coreSessionMessage.User, SessionMessage.User],
|
||||
[coreSessionMessage.Synthetic, SessionMessage.Synthetic],
|
||||
[coreSessionMessage.System, SessionMessage.System],
|
||||
|
|
|
|||
|
|
@ -1,36 +1,42 @@
|
|||
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 { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
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/"
|
||||
type Fixture = {
|
||||
tmp: Awaited<ReturnType<typeof tmpdir>>
|
||||
server: Bun.Server<undefined>
|
||||
state: {
|
||||
skills: unknown[]
|
||||
files: Record<string, string>
|
||||
requests: string[]
|
||||
}
|
||||
base: string
|
||||
}
|
||||
|
||||
async function pull(skills: unknown[], files: Record<string, string> = {}, cache?: Awaited<ReturnType<typeof tmpdir>>) {
|
||||
const tmp = cache ?? (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 }),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
async function pull(skills: unknown[], files: Record<string, string> = {}, fixture?: Fixture) {
|
||||
const state = fixture?.state ?? { skills, files, requests: [] }
|
||||
state.skills = skills
|
||||
state.files = files
|
||||
state.requests = []
|
||||
const server =
|
||||
fixture?.server ??
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
state.requests.push(request.url)
|
||||
const pathname = new URL(request.url).pathname
|
||||
const body = pathname === "/catalog/index.json" ? JSON.stringify({ skills: state.skills }) : state.files[pathname]
|
||||
return new Response(body ?? "Not Found", { status: body === undefined ? 404 : 200 })
|
||||
},
|
||||
})
|
||||
const tmp = fixture?.tmp ?? (await tmpdir())
|
||||
const base = fixture?.base ?? new URL("/catalog/", server.url).href
|
||||
const skillDiscoveryLayer = AppNodeBuilder.build(SkillDiscovery.node, [
|
||||
[LayerNodePlatform.httpClient, http],
|
||||
[Global.node, Global.layerWith({ cache: tmp.path })],
|
||||
])
|
||||
const directories = await Effect.runPromise(
|
||||
|
|
@ -38,7 +44,12 @@ async function pull(skills: unknown[], files: Record<string, string> = {}, cache
|
|||
return yield* (yield* SkillDiscovery.Service).pull(base)
|
||||
}).pipe(Effect.provide(skillDiscoveryLayer)),
|
||||
)
|
||||
return { tmp, requests, directories }
|
||||
return { tmp, server, state, base, requests: state.requests, directories }
|
||||
}
|
||||
|
||||
async function dispose(fixture: Fixture) {
|
||||
await fixture.server.stop(true)
|
||||
await fixture.tmp[Symbol.asyncDispose]()
|
||||
}
|
||||
|
||||
describe("SkillDiscovery.pull", () => {
|
||||
|
|
@ -46,10 +57,10 @@ describe("SkillDiscovery.pull", () => {
|
|||
const result = await pull([{ name: "../outside", files: ["SKILL.md"] }])
|
||||
try {
|
||||
expect(result.directories).toEqual([])
|
||||
expect(result.requests).toEqual([`${base}index.json`])
|
||||
expect(result.requests).toEqual([`${result.base}index.json`])
|
||||
expect(await fs.readdir(result.tmp.path)).toEqual([])
|
||||
} finally {
|
||||
await result.tmp[Symbol.asyncDispose]()
|
||||
await dispose(result)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -57,10 +68,10 @@ describe("SkillDiscovery.pull", () => {
|
|||
const result = await pull([{ name: "deploy", files: ["SKILL.md", "../outside.md"] }])
|
||||
try {
|
||||
expect(result.directories).toEqual([])
|
||||
expect(result.requests).toEqual([`${base}index.json`])
|
||||
expect(result.requests).toEqual([`${result.base}index.json`])
|
||||
expect(await fs.readdir(result.tmp.path)).toEqual([])
|
||||
} finally {
|
||||
await result.tmp[Symbol.asyncDispose]()
|
||||
await dispose(result)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -68,10 +79,10 @@ describe("SkillDiscovery.pull", () => {
|
|||
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(result.requests).toEqual([`${result.base}index.json`])
|
||||
expect(await fs.readdir(result.tmp.path)).toEqual([])
|
||||
} finally {
|
||||
await result.tmp[Symbol.asyncDispose]()
|
||||
await dispose(result)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -79,87 +90,87 @@ describe("SkillDiscovery.pull", () => {
|
|||
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(result.requests).toEqual([`${result.base}index.json`])
|
||||
expect(await fs.readdir(result.tmp.path)).toEqual([])
|
||||
} finally {
|
||||
await result.tmp[Symbol.asyncDispose]()
|
||||
await dispose(result)
|
||||
}
|
||||
})
|
||||
|
||||
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",
|
||||
"/catalog/deploy/SKILL.md": "# Deploy",
|
||||
"/catalog/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(),
|
||||
[
|
||||
`${result.base}index.json`,
|
||||
`${result.base}deploy/SKILL.md`,
|
||||
`${result.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]()
|
||||
await dispose(result)
|
||||
}
|
||||
})
|
||||
|
||||
test("refreshes cached files when the version changes", async () => {
|
||||
const tmp = await tmpdir()
|
||||
const first = await pull(
|
||||
[{ name: "deploy", version: "1", files: ["SKILL.md"] }],
|
||||
{ "/catalog/deploy/SKILL.md": "# Old" },
|
||||
)
|
||||
try {
|
||||
const first = await pull(
|
||||
[{ name: "deploy", version: "1", files: ["SKILL.md"] }],
|
||||
{
|
||||
[`${base}deploy/SKILL.md`]: "# Old",
|
||||
},
|
||||
tmp,
|
||||
)
|
||||
const second = await pull(
|
||||
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
|
||||
{
|
||||
[`${base}deploy/SKILL.md`]: "# New",
|
||||
},
|
||||
tmp,
|
||||
{ "/catalog/deploy/SKILL.md": "# New" },
|
||||
first,
|
||||
)
|
||||
|
||||
expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New")
|
||||
expect(second.requests).toContain(`${base}deploy/SKILL.md`)
|
||||
expect(second.requests).toContain(`${first.base}deploy/SKILL.md`)
|
||||
const third = await pull(
|
||||
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
|
||||
{ [`${base}deploy/SKILL.md`]: "# Ignored" },
|
||||
tmp,
|
||||
{ "/catalog/deploy/SKILL.md": "# Ignored" },
|
||||
first,
|
||||
)
|
||||
expect(third.requests).toEqual([`${base}index.json`])
|
||||
expect(third.requests).toEqual([`${first.base}index.json`])
|
||||
} finally {
|
||||
await tmp[Symbol.asyncDispose]()
|
||||
await dispose(first)
|
||||
}
|
||||
})
|
||||
|
||||
test("publishes complete updates and removes stale files", async () => {
|
||||
const tmp = await tmpdir()
|
||||
const first = await pull(
|
||||
[{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }],
|
||||
{
|
||||
"/catalog/deploy/SKILL.md": "# Old",
|
||||
"/catalog/deploy/old.md": "old reference",
|
||||
},
|
||||
)
|
||||
try {
|
||||
const first = await pull(
|
||||
[{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }],
|
||||
{
|
||||
[`${base}deploy/SKILL.md`]: "# Old",
|
||||
[`${base}deploy/old.md`]: "old reference",
|
||||
},
|
||||
tmp,
|
||||
)
|
||||
const root = first.directories[0]
|
||||
|
||||
await pull(
|
||||
[{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }],
|
||||
{ [`${base}deploy/SKILL.md`]: "# Partial" },
|
||||
tmp,
|
||||
{ "/catalog/deploy/SKILL.md": "# Partial" },
|
||||
first,
|
||||
)
|
||||
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old")
|
||||
expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference")
|
||||
|
||||
await pull([{ name: "deploy", version: "3", files: ["SKILL.md"] }], { [`${base}deploy/SKILL.md`]: "# New" }, tmp)
|
||||
await pull(
|
||||
[{ name: "deploy", version: "3", files: ["SKILL.md"] }],
|
||||
{ "/catalog/deploy/SKILL.md": "# New" },
|
||||
first,
|
||||
)
|
||||
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New")
|
||||
expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false)
|
||||
} finally {
|
||||
await tmp[Symbol.asyncDispose]()
|
||||
await dispose(first)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ 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 { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
|
|
@ -206,7 +206,7 @@ metadata:
|
|||
waitForSkillUpdate(),
|
||||
({ deferred }) =>
|
||||
events
|
||||
.publish(FileSystemWatcher.Event.Updated, { file, event: "change" })
|
||||
.publish(FileSystem.Event.Changed, { file, event: "change" })
|
||||
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,8 +16,15 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
|||
import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const applyPatchToolNode = makeLocationNode({
|
||||
name: "test/apply-patch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(ApplyPatchTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
|
|
@ -101,7 +108,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
|
|||
ToolRegistry.toolsNode,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
ApplyPatchTool.node,
|
||||
applyPatchToolNode,
|
||||
]),
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
|
|
|
|||
|
|
@ -17,8 +17,15 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
|||
import { EditTool } from "@opencode-ai/core/tool/edit"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const editToolNode = makeLocationNode({
|
||||
name: "test/edit-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_edit_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
|
|
@ -91,7 +98,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
|
|||
ToolRegistry.toolsNode,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
EditTool.node,
|
||||
editToolNode,
|
||||
]),
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
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 { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_question_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
let captured: QuestionV2.AskInput | undefined
|
||||
let captured: Form.CreateInput | undefined
|
||||
let reject = false
|
||||
let deny = false
|
||||
const capturedInput = () => captured
|
||||
|
|
@ -31,22 +32,38 @@ const permission = Layer.succeed(
|
|||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const question = Layer.succeed(
|
||||
QuestionV2.Service,
|
||||
QuestionV2.Service.of({
|
||||
ask: (input: QuestionV2.AskInput) =>
|
||||
const form = Layer.succeed(
|
||||
Form.Service,
|
||||
Form.Service.of({
|
||||
ask: (input: Form.CreateInput) =>
|
||||
Effect.sync(() => {
|
||||
captured = input
|
||||
}).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
|
||||
reply: () => Effect.die("unused"),
|
||||
reject: () => Effect.die("unused"),
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(
|
||||
(): Form.TerminalState =>
|
||||
reject ? { status: "cancelled" } : { status: "answered", answer: { q0: "Build", q1: ["Dev"] } },
|
||||
),
|
||||
),
|
||||
),
|
||||
create: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
state: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const questionToolNode = makeLocationNode({
|
||||
name: "test/question-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(QuestionTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, Form.node],
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, questionToolNode]), [
|
||||
[PermissionV2.node, permission],
|
||||
[QuestionV2.node, question],
|
||||
[Form.node, form],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
]),
|
||||
)
|
||||
|
|
@ -88,6 +105,12 @@ describe("QuestionTool", () => {
|
|||
question: "Which environment?",
|
||||
header: "Environment",
|
||||
options: [{ label: "Dev", description: "Development" }],
|
||||
multiple: true,
|
||||
},
|
||||
{
|
||||
question: "Anything else?",
|
||||
header: "Optional",
|
||||
options: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -102,14 +125,14 @@ describe("QuestionTool", () => {
|
|||
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.',
|
||||
'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
|
||||
},
|
||||
output: {
|
||||
structured: { answers: [["Build"], []] },
|
||||
structured: { answers: [["Build"], ["Dev"], []] },
|
||||
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.',
|
||||
text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -117,8 +140,34 @@ describe("QuestionTool", () => {
|
|||
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
|
||||
expect(capturedInput()).toEqual({
|
||||
sessionID,
|
||||
questions,
|
||||
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||
mode: "form",
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
title: "Action",
|
||||
description: "What should happen?",
|
||||
options: [{ value: "Build", label: "Build", description: "Build it" }],
|
||||
custom: true,
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
key: "q1",
|
||||
title: "Environment",
|
||||
description: "Which environment?",
|
||||
options: [{ value: "Dev", label: "Dev", description: "Development" }],
|
||||
custom: true,
|
||||
type: "multiselect",
|
||||
},
|
||||
{
|
||||
key: "q2",
|
||||
title: "Optional",
|
||||
description: "Anything else?",
|
||||
options: [],
|
||||
custom: true,
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -137,8 +186,9 @@ describe("QuestionTool", () => {
|
|||
})
|
||||
expect(capturedInput()).toEqual({
|
||||
sessionID,
|
||||
questions: [],
|
||||
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||
mode: "form",
|
||||
fields: [],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -157,6 +207,11 @@ describe("QuestionTool", () => {
|
|||
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const error = Cause.squash(exit.cause)
|
||||
expect(error).toBeInstanceOf(QuestionTool.CancelledError)
|
||||
expect(error).toHaveProperty("message", "The user dismissed this question")
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,8 +19,25 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
|||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ReadTool } from "@opencode-ai/core/tool/read"
|
||||
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const readToolNode = makeLocationNode({
|
||||
name: "test/read-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(ReadTool.Plugin)),
|
||||
deps: [
|
||||
ToolRegistry.toolsNode,
|
||||
ReadToolFileSystem.node,
|
||||
LocationMutation.node,
|
||||
Image.node,
|
||||
PermissionV2.node,
|
||||
SessionInstructions.node,
|
||||
FSUtil.node,
|
||||
Location.node,
|
||||
],
|
||||
})
|
||||
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
const missingPath = "__missing_read_target__.txt"
|
||||
|
|
@ -130,7 +147,7 @@ const unavailableImage = Layer.succeed(
|
|||
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
|
||||
)
|
||||
const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, ReadTool.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, readToolNode]), [
|
||||
[ReadToolFileSystem.node, reader],
|
||||
[PermissionV2.node, permission],
|
||||
[Config.node, config],
|
||||
|
|
|
|||
87
packages/core/test/tool-search.test.ts
Normal file
87
packages/core/test/tool-search.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { GlobTool } from "@opencode-ai/core/tool/glob"
|
||||
import { GrepTool } from "@opencode-ai/core/tool/grep"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
|
||||
const globToolNode = makeLocationNode({
|
||||
name: "test/glob-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
|
||||
})
|
||||
const grepToolNode = makeLocationNode({
|
||||
name: "test/grep-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
|
||||
})
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: () => Effect.void,
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const sessionID = SessionV2.ID.make("ses_search_tool_test")
|
||||
|
||||
const withTools = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, globToolNode, grepToolNode]), [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
const call = (name: "glob" | "grep", input: unknown) => ({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call" as const, id: `call-${name}`, name, input },
|
||||
})
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("search tools", () => {
|
||||
for (const name of ["glob", "grep"] as const) {
|
||||
it.live(`${name} reports a missing search path`, () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
withTools(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* executeTool(
|
||||
registry,
|
||||
call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }),
|
||||
)
|
||||
expect(result).toEqual({ type: "error", value: "Search path does not exist: missing" })
|
||||
}),
|
||||
),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
@ -79,27 +79,23 @@ const executionNode = makeGlobalNode({
|
|||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: session.agent ?? AgentV2.ID.make("code"),
|
||||
model: sessionModel,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
textID,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
textID,
|
||||
text: "ok",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: id,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
|
|
@ -175,9 +171,11 @@ const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.I
|
|||
})
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locationLayer = locations.get(location)
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locationLayer))
|
||||
yield* waitForTool(registry, ShellTool.name)
|
||||
return yield* body(registry).pipe(Effect.provide(locationLayer))
|
||||
return yield* Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, ShellTool.name)
|
||||
return yield* body(registry)
|
||||
}).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
|
||||
})
|
||||
|
||||
describe("ShellTool", () => {
|
||||
|
|
@ -482,9 +480,13 @@ describe("ShellTool", () => {
|
|||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||
expect(settled.output?.structured).toMatchObject({ truncated: false })
|
||||
expect(settled.output?.content[0]).toMatchObject({
|
||||
expect(settled.output?.content[0]).toEqual({
|
||||
type: "text",
|
||||
text: expect.stringContaining("running in the background"),
|
||||
text: "The command was moved to the background.",
|
||||
})
|
||||
expect(settled.output?.content[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("DO NOT sleep, poll"),
|
||||
})
|
||||
expect(shellID).toStartWith("sh_")
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,15 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
|||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const skillToolNode = makeLocationNode({
|
||||
name: "test/skill-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(SkillTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, FSUtil.node, SkillV2.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_skill_tool_test")
|
||||
|
||||
|
|
@ -66,7 +74,7 @@ describe("SkillTool", () => {
|
|||
}),
|
||||
)
|
||||
const skillToolLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, SkillTool.node]),
|
||||
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, skillToolNode]),
|
||||
[
|
||||
[PermissionV2.node, permission],
|
||||
[SkillV2.node, skills],
|
||||
|
|
|
|||
|
|
@ -53,27 +53,23 @@ const executionNode = makeGlobalNode({
|
|||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: AgentV2.ID.make("reviewer"),
|
||||
model: childModel,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
textID,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
textID,
|
||||
text: childText,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,14 @@ import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
|
|||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const todoWriteToolNode = makeLocationNode({
|
||||
name: "test/todowrite-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(TodoWriteTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, SessionTodo.node],
|
||||
})
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_todowrite_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
|
|
@ -43,7 +50,7 @@ const it = testEffect(
|
|||
SessionTodo.node,
|
||||
ToolRegistry.node,
|
||||
ToolRegistry.toolsNode,
|
||||
TodoWriteTool.node,
|
||||
todoWriteToolNode,
|
||||
]),
|
||||
[
|
||||
[PermissionV2.node, permission],
|
||||
|
|
|
|||
|
|
@ -10,8 +10,15 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const webFetchToolNode = makeLocationNode({
|
||||
name: "test/webfetch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WebFetchTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient],
|
||||
})
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_webfetch_test")
|
||||
const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
|
||||
|
|
@ -40,7 +47,7 @@ const permission = Layer.succeed(
|
|||
}),
|
||||
)
|
||||
const toolLayer = (replacements: LayerNode.Replacements = []) =>
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebFetchTool.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, webFetchToolNode]), [
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
...replacements,
|
||||
|
|
|
|||
|
|
@ -9,8 +9,15 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const webSearchToolNode = makeLocationNode({
|
||||
name: "test/websearch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WebSearchTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, PermissionV2.node, LayerNodePlatform.httpClient, WebSearchTool.configNode],
|
||||
})
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_websearch_test")
|
||||
const payload = (text: string) =>
|
||||
|
|
@ -125,7 +132,7 @@ const websearchConfig = Layer.succeed(
|
|||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, WebSearchTool.node]),
|
||||
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, webSearchToolNode]),
|
||||
[
|
||||
[PermissionV2.node, permission],
|
||||
[LayerNodePlatform.httpClient, http],
|
||||
|
|
|
|||
|
|
@ -17,8 +17,15 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
|||
import { WriteTool } from "@opencode-ai/core/tool/write"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const writeToolNode = makeLocationNode({
|
||||
name: "test/write-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
|
||||
deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_write_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
|
|
@ -75,7 +82,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Inte
|
|||
ToolRegistry.toolsNode,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
WriteTool.node,
|
||||
writeToolNode,
|
||||
]),
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue