feat(core): add configurable agent state
This commit is contained in:
parent
0e638d254d
commit
8e4cbf6087
11 changed files with 503 additions and 12 deletions
105
packages/core/test/agent.test.ts
Normal file
105
packages/core/test/agent.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Scope } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AgentV2.defaultLayer)
|
||||
|
||||
describe("AgentV2", () => {
|
||||
it.effect("starts without agents", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
|
||||
expect(yield* agent.all()).toEqual([])
|
||||
expect(yield* agent.get(AgentV2.ID.make("build"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("materializes replayable agent transforms", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("reviewer")
|
||||
const transform = yield* agent.transform()
|
||||
|
||||
yield* transform((editor) =>
|
||||
editor.update(id, (info) => {
|
||||
info.description = "Reviews code"
|
||||
info.mode = "subagent"
|
||||
}),
|
||||
)
|
||||
|
||||
expect(yield* agent.get(id)).toMatchObject({ id, description: "Reviews code", mode: "subagent" })
|
||||
expect((yield* agent.all()).map((info) => info.id)).toEqual([id])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rebuilds state when a transform is replaced", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("reviewer")
|
||||
const transform = yield* agent.transform()
|
||||
|
||||
yield* transform((editor) =>
|
||||
editor.update(id, (info) => {
|
||||
info.description = "Old description"
|
||||
info.hidden = true
|
||||
}),
|
||||
)
|
||||
yield* transform((editor) =>
|
||||
editor.update(id, (info) => {
|
||||
info.description = "New description"
|
||||
}),
|
||||
)
|
||||
|
||||
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes a transform contribution when its scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("scoped")
|
||||
const scope = yield* Scope.make()
|
||||
const transform = yield* agent.transform().pipe(Scope.provide(scope))
|
||||
|
||||
yield* transform((editor) => editor.update(id, () => {}))
|
||||
expect(yield* agent.get(id)).toBeDefined()
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* agent.get(id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies direct agent updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("build")
|
||||
|
||||
yield* agent.update((editor) =>
|
||||
Effect.sync(() =>
|
||||
editor.update(id, (info) => {
|
||||
info.mode = "primary"
|
||||
info.hidden = true
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* agent.get(id)).toMatchObject({ id, mode: "primary", hidden: true })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates agents with runtime defaults and supports direct removal", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("custom")
|
||||
|
||||
yield* agent.update((editor) => Effect.sync(() => editor.update(id, () => {})))
|
||||
expect(yield* agent.get(id)).toEqual(
|
||||
AgentV2.Info.empty(id),
|
||||
)
|
||||
|
||||
yield* agent.update((editor) => Effect.sync(() => editor.remove(id)))
|
||||
expect(yield* agent.get(id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
186
packages/core/test/config/agent.test.ts
Normal file
186
packages/core/test/config/agent.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AgentV2.defaultLayer)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("ConfigAgentPlugin.Plugin", () => {
|
||||
it.effect("applies global permissions between built-in and agent-specific permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const build = AgentV2.ID.make("build")
|
||||
const defaults = yield* agents.transform()
|
||||
|
||||
yield* defaults((editor) =>
|
||||
editor.update(build, (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.permissions.push({ permission: "bash", pattern: "*", action: "allow" })
|
||||
}),
|
||||
)
|
||||
|
||||
const config = Config.Service.of({
|
||||
directories: () => Effect.succeed([]),
|
||||
get: () =>
|
||||
Effect.succeed([
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
permissions: [{ permission: "bash", pattern: "*", action: "ask" }],
|
||||
agents: {
|
||||
build: {
|
||||
permissions: [{ permission: "bash", pattern: "git *", action: "allow" }],
|
||||
},
|
||||
reviewer: {
|
||||
model: "openrouter/openai/gpt-5",
|
||||
description: "Review changes",
|
||||
mode: "subagent",
|
||||
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
|
||||
},
|
||||
removed: { description: "Removed later" },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
agents: {
|
||||
reviewer: { variant: "high", hidden: true },
|
||||
removed: { disabled: true },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
yield* ConfigAgentPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
)
|
||||
|
||||
const buildAgent = yield* agents.get(build)
|
||||
if (!buildAgent) throw new Error("expected configured build agent")
|
||||
expect(buildAgent.permissions).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "bash", pattern: "git *", action: "allow" },
|
||||
])
|
||||
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).action).toBe("allow")
|
||||
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).action).toBe("ask")
|
||||
|
||||
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
|
||||
if (!reviewer) throw new Error("expected configured reviewer agent")
|
||||
expect(reviewer).toMatchObject({
|
||||
description: "Review changes",
|
||||
mode: "subagent",
|
||||
hidden: true,
|
||||
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
|
||||
})
|
||||
expect(reviewer.permissions).toEqual([
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "edit", pattern: "*", action: "deny" },
|
||||
])
|
||||
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const config = Config.Service.of({
|
||||
directories: () => Effect.succeed([]),
|
||||
get: () =>
|
||||
Effect.succeed([
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
agents: {
|
||||
reviewer: {
|
||||
model: "anthropic/claude-sonnet",
|
||||
system: "Review carefully.",
|
||||
description: "Reviews changes",
|
||||
mode: "subagent",
|
||||
hidden: true,
|
||||
color: "warning",
|
||||
steps: 12,
|
||||
options: {
|
||||
headers: { first: "one", shared: "first" },
|
||||
body: { enabled: true },
|
||||
aisdk: { provider: { profile: "review" }, request: { effort: "medium" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
agents: {
|
||||
reviewer: {
|
||||
options: {
|
||||
headers: { shared: "last", second: "two" },
|
||||
body: { retries: 2 },
|
||||
aisdk: { request: { effort: "high" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
yield* ConfigAgentPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
)
|
||||
|
||||
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
|
||||
if (!reviewer) throw new Error("expected configured reviewer agent")
|
||||
expect(reviewer).toMatchObject({
|
||||
system: "Review carefully.",
|
||||
description: "Reviews changes",
|
||||
mode: "subagent",
|
||||
hidden: true,
|
||||
color: "warning",
|
||||
steps: 12,
|
||||
model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined },
|
||||
})
|
||||
expect(reviewer.options).toEqual({
|
||||
headers: { first: "one", shared: "last", second: "two" },
|
||||
body: { enabled: true, retries: 2 },
|
||||
aisdk: { provider: { profile: "review" }, request: { effort: "high" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes a built-in agent disabled by configuration", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const build = AgentV2.ID.make("build")
|
||||
const defaults = yield* agents.transform()
|
||||
yield* defaults((editor) => editor.update(build, () => {}))
|
||||
|
||||
const config = Config.Service.of({
|
||||
directories: () => Effect.succeed([]),
|
||||
get: () =>
|
||||
Effect.succeed([
|
||||
new Config.Loaded({
|
||||
source: { type: "memory" },
|
||||
info: decode({ agents: { build: { disabled: true } } }),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
yield* ConfigAgentPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
)
|
||||
|
||||
expect(yield* agents.get(build)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -105,7 +105,6 @@ describe("Config", () => {
|
|||
expect(documents.map((document) => document.source.type)).toEqual(["file", "file", "file"])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
|
||||
expect(documents[0]).toBeInstanceOf(Config.Loaded)
|
||||
expect(documents[0]?.source).toBeInstanceOf(Config.FileSource)
|
||||
expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe(
|
||||
path.join(tmp.path, "config.json"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
get: () =>
|
||||
Effect.succeed([
|
||||
new Config.Loaded({
|
||||
source: new Config.MemorySource({ type: "memory" }),
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: {
|
||||
|
|
@ -58,7 +58,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
}),
|
||||
}),
|
||||
new Config.Loaded({
|
||||
source: new Config.MemorySource({ type: "memory" }),
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: {
|
||||
|
|
@ -87,7 +87,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
}),
|
||||
}),
|
||||
new Config.Loaded({
|
||||
source: new Config.MemorySource({ type: "memory" }),
|
||||
source: { type: "memory" },
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: { name: "Renamed" },
|
||||
|
|
|
|||
23
packages/core/test/model.test.ts
Normal file
23
packages/core/test/model.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
|
||||
const decode = Schema.decodeUnknownSync(ModelV2.Ref)
|
||||
|
||||
describe("ModelV2.Ref", () => {
|
||||
test("accepts a model selection without a variant", () => {
|
||||
expect(decode({ id: "claude-sonnet", providerID: "anthropic" })).toEqual({
|
||||
id: ModelV2.ID.make("claude-sonnet"),
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves an explicit model variant", () => {
|
||||
expect(decode({ id: "claude-sonnet", providerID: "anthropic", variant: "high" })).toEqual({
|
||||
id: ModelV2.ID.make("claude-sonnet"),
|
||||
providerID: ProviderV2.ID.make("anthropic"),
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue