feat(core): admit v2 skill guidance (#30843)

This commit is contained in:
Kit Langton 2026-06-05 11:19:55 -04:00 committed by GitHub
commit 3f64b5e621
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3119 additions and 174 deletions

View file

@ -51,7 +51,6 @@ describe("ConfigSkillPlugin.Plugin", () => {
transform,
sources: () => Effect.succeed(sources),
list: () => Effect.succeed([]),
forAgent: () => Effect.succeed([]),
}),
),
)

View file

@ -12,6 +12,7 @@ import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input"
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@ -67,6 +68,11 @@ describe("DatabaseMigration", () => {
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`),
).toEqual({ name: "session_context_epoch" })
expect(
yield* db.get(
sql`SELECT name, dflt_value FROM pragma_table_info('session_context_epoch') WHERE name = 'agent'`,
),
).toEqual({ name: "agent", dflt_value: "'build'" })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
expect(
yield* db.all(
@ -86,6 +92,26 @@ describe("DatabaseMigration", () => {
)
})
test("backfills existing Context Epoch rows to the build agent", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(
sql`CREATE TABLE session_context_epoch (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL, replacement_seq integer, revision integer DEFAULT 0 NOT NULL)`,
)
yield* db.run(
sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('ses_existing', 'baseline', '{}', 0)`,
)
yield* DatabaseMigration.applyOnly(db, [contextEpochAgentMigration])
expect(yield* db.get(sql`SELECT agent FROM session_context_epoch WHERE session_id = 'ses_existing'`)).toEqual({
agent: "build",
})
}),
)
})
test("resets beta history and rebuilds event-sourced Session input storage", async () => {
await run(
Effect.gen(function* () {

View file

@ -8,7 +8,7 @@ import { InstructionContext } from "@opencode-ai/core/instruction-context"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"

View file

@ -126,6 +126,29 @@ describe("PermissionV2", () => {
}),
)
it.effect("evaluates against an explicit provider-turn agent", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const agents = yield* AgentV2.Service
yield* agents.update((editor) =>
editor.update(AgentV2.ID.make("reviewer"), (agent) => {
agent.permissions.push({ action: "read", resource: "*", effect: "deny" })
}),
)
const service = yield* PermissionV2.Service
expect(yield* service.ask(assertion())).toMatchObject({ effect: "allow" })
expect(yield* service.ask(assertion({ agent: AgentV2.ID.make("reviewer") }))).toMatchObject({ effect: "deny" })
yield* agents.update((editor) =>
editor.update(AgentV2.ID.make("reviewer"), (agent) => {
agent.permissions = []
}),
)
expect(yield* service.ask(assertion({ agent: AgentV2.ID.make("reviewer") }))).toMatchObject({ effect: "ask" })
expect(yield* service.get(PermissionV2.ID.create("per_test"))).not.toHaveProperty("agent")
}),
)
it.effect("allows and denies from explicit rules without asking", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])

View file

@ -20,7 +20,9 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
@ -59,6 +61,7 @@ const model = OpenAIChat.route
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
const systemContext = SystemContextRegistry.layer
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(database),
Layer.provide(store),
@ -68,6 +71,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(models),
Layer.provide(systemContext),
Layer.provide(agents),
Layer.provide(skillGuidance),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
@ -96,6 +100,7 @@ const it = testEffect(
registry,
models,
systemContext,
skillGuidance,
runner,
coordinator,
execution,

View file

@ -26,6 +26,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionContextEpoch } from "@opencode-ai/core/session/context-epoch"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
@ -42,7 +43,8 @@ import {
} from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
@ -146,14 +148,16 @@ const echo = Layer.effectDiscard(
}),
),
).pipe(Layer.provide(registry))
let modelResolveHook = Effect.void
const models = SessionRunnerModel.layerWith((session) =>
Effect.succeed(session.model?.id === "replacement" ? replacementModel : model),
modelResolveHook.pipe(Effect.as(session.model?.id === "replacement" ? replacementModel : model)),
)
const systemContextKey = SystemContext.Key.make("test/context")
let systemBaseline = "Initial context"
let systemRemoved = false
let systemUnavailable = false
let systemLoadHook = Effect.void
const skillBaselines = new Map<AgentV2.ID, string>()
const systemContext = Layer.effectDiscard(
SystemContextRegistry.Service.pipe(
Effect.flatMap((registry) =>
@ -183,6 +187,21 @@ const systemContext = Layer.effectDiscard(
),
),
).pipe(Layer.provideMerge(SystemContextRegistry.layer))
const skillGuidance = Layer.mock(SkillGuidance.Service, {
load: (agent) =>
Effect.succeed(
skillBaselines.has(agent.id)
? SystemContext.make({
key: SystemContext.Key.make("test/skill-guidance"),
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed(skillBaselines.get(agent.id)!),
baseline: String,
update: (_previous, current) => current,
removed: () => "Skill guidance removed",
})
: SystemContext.empty,
),
})
const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(database),
Layer.provide(store),
@ -192,6 +211,7 @@ const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(models),
Layer.provide(systemContext),
Layer.provide(agents),
Layer.provide(skillGuidance),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
@ -222,6 +242,7 @@ const it = testEffect(
echo,
models,
systemContext,
skillGuidance,
runner,
coordinator,
execution,
@ -256,6 +277,8 @@ const setup = Effect.gen(function* () {
systemRemoved = false
systemUnavailable = false
systemLoadHook = Effect.void
modelResolveHook = Effect.void
skillBaselines.clear()
responses = undefined
streamFailure = undefined
responseStream = undefined
@ -805,6 +828,304 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("composes selected-agent skill guidance and replaces it after an agent switch", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer",
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context\n\nBuild skills"],
["Initial context\n\nReviewer skills"],
])
}),
)
it.effect("retries first-epoch preparation when the selected agent changes during observation", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
let switched = false
systemLoadHook = Effect.suspend(() => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer",
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context\n\nReviewer skills"],
])
}),
)
it.effect("opens a queued activity once when the selected agent changes during observation", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
let switched = false
systemLoadHook = Effect.suspend(() => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer",
})
.pipe(Effect.asVoid)
})
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Queued" }),
delivery: "queue",
resume: false,
})
requests.length = 0
response = []
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect((yield* session.context(sessionID)).filter((message) => message.type === "user")).toHaveLength(1)
}),
)
it.effect("retries an agent switch before the final provider-dispatch boundary", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
let switched = false
modelResolveHook = Effect.suspend(() => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer",
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context\n\nReviewer skills"],
])
expect(
yield* db
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
).toEqual({ replacementSeq: null })
}),
)
it.effect("retries a model switch before the final provider-dispatch boundary", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
let switched = false
modelResolveHook = Effect.suspend(() => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
expect(requests.map((request) => request.model)).toEqual([replacementModel])
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([["Initial context"]])
}),
)
it.effect("fences an unchanged epoch read across an agent ABA replacement request", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
let switched = false
systemLoadHook = Effect.suspend(() => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: AgentV2.ID.make("reviewer"),
})
.pipe(
Effect.andThen(
events.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(2),
agent: AgentV2.defaultID,
}),
),
Effect.asVoid,
)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
requests.length = 0
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(
yield* db
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
).toEqual({ replacementSeq: null })
}),
)
it.effect("rejects stale agent guidance when committing an existing-epoch replacement", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: AgentV2.ID.make("reviewer"),
})
const context = (text: string) =>
Effect.succeed(
SystemContext.make({
key: systemContextKey,
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed(text),
baseline: String,
update: (_previous, current) => current,
}),
)
const location = (yield* session.get(sessionID)).location
expect(
yield* SessionContextEpoch.prepare(
db,
events,
context("Stale build context"),
sessionID,
location,
AgentV2.defaultID,
).pipe(Effect.catchDefect(Effect.succeed)),
).toBeInstanceOf(SessionContextEpoch.AgentMismatch)
expect(
yield* SessionContextEpoch.prepare(
db,
events,
context("Reviewer context"),
sessionID,
location,
AgentV2.ID.make("reviewer"),
),
).toMatchObject({ baseline: "Reviewer context" })
}),
)
it.effect("blocks a cross-agent provider turn while replacement context is unavailable", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
skillBaselines.set(AgentV2.defaultID, "Build skills")
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: AgentV2.ID.make("reviewer"),
})
systemUnavailable = true
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
requests.length = 0
const blocked = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(blocked)).toBe(true)
if (Exit.isFailure(blocked))
expect(Cause.squash(blocked.cause)).toBeInstanceOf(SessionContextEpoch.AgentReplacementBlocked)
expect(requests).toHaveLength(0)
systemUnavailable = false
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context\n\nReviewer skills"],
])
}),
)
it.effect("admits removed context as a chronological System message", () =>
Effect.gen(function* () {
yield* setup

View file

@ -121,8 +121,7 @@ describe("SkillV2", () => {
expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"])
expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"])
expect(pulls).toBe(1)
expect(yield* skill.forAgent(AgentV2.ID.make("reviewer"))).toEqual([])
expect(yield* skill.forAgent(AgentV2.ID.make("missing"))).toEqual([])
expect(SkillV2.available(yield* skill.list(), (yield* agents.get(AgentV2.ID.make("reviewer")))!)).toEqual([])
}),
),
),

View file

@ -0,0 +1,154 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { it } from "../lib/effect"
const build = AgentV2.ID.make("build")
const effect = new SkillV2.Info({
name: "effect",
description: "Build applications with Effect",
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
content: "Effect guidance",
})
const hidden = new SkillV2.Info({
name: "hidden",
location: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")),
content: "Undescribed guidance",
})
const denied = new SkillV2.Info({
name: "denied",
description: "Must not be advertised",
location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")),
content: "Denied guidance",
})
const layer = (list: () => SkillV2.Info[], wait: () => void = () => {}) =>
SkillGuidance.layer.pipe(
Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })),
Layer.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.sync(wait) })),
)
describe("SkillGuidance", () => {
it.effect("renders described agent skills and reconciles the complete available list", () => {
const agent = new AgentV2.Info({
...AgentV2.Info.empty(build),
permissions: [{ action: "skill", resource: "denied", effect: "deny" }],
})
let skills = [hidden, denied, effect]
let waited = 0
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
const initialized = yield* guidance
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap(SystemContext.initialize))
expect(waited).toBe(1)
expect(initialized.baseline).toBe(
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"<available_skills>",
" <skill>",
" <name>effect</name>",
" <description>Build applications with Effect</description>",
" </skill>",
"</available_skills>",
].join("\n"),
)
skills = []
expect(
yield* guidance
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.snapshot))),
).toMatchObject({
_tag: "Updated",
text: expect.stringContaining("No skills are currently available."),
})
}).pipe(
Effect.provide(
layer(
() => skills,
() => waited++,
),
),
)
})
it.effect("omits guidance when the selected agent denies all skills", () => {
const agent = new AgentV2.Info({
...AgentV2.Info.empty(build),
permissions: [{ action: "skill", resource: "*", effect: "deny" }],
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect(
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
).toEqual({
baseline: "",
snapshot: {},
})
}).pipe(Effect.provide(layer(() => [effect])))
})
it.effect("omits guidance when a resource-specific denial follows the global denial", () => {
const agent = new AgentV2.Info({
...AgentV2.Info.empty(build),
permissions: [
{ action: "skill", resource: "*", effect: "deny" },
{ action: "skill", resource: "hidden", effect: "deny" },
],
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect(
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
).toEqual({
baseline: "",
snapshot: {},
})
}).pipe(Effect.provide(layer(() => [effect])))
})
it.effect("retains specifically allowed skills after a global denial", () => {
const agent = new AgentV2.Info({
...AgentV2.Info.empty(build),
permissions: [
{ action: "skill", resource: "*", effect: "deny" },
{ action: "skill", resource: "effect", effect: "allow" },
],
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect(
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).baseline,
).toContain("<name>effect</name>")
}).pipe(Effect.provide(layer(() => [effect])))
})
it.effect("omits guidance when a specifically allowed skill is denied again", () => {
const agent = new AgentV2.Info({
...AgentV2.Info.empty(build),
permissions: [
{ action: "skill", resource: "*", effect: "deny" },
{ action: "skill", resource: "effect", effect: "allow" },
{ action: "skill", resource: "effect", effect: "deny" },
],
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect(
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
).toEqual({
baseline: "",
snapshot: {},
})
}).pipe(Effect.provide(layer(() => [effect])))
})
})

View file

@ -6,10 +6,10 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context-builtins"
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))

View file

@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Schema } from "effect"
import { SystemContext } from "@opencode-ai/core/system-context"
import { it } from "./lib/effect"
import { it } from "../lib/effect"
const key = SystemContext.Key.make
const stringContext = (input: {

View file

@ -1,8 +1,8 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Schema, Scope } from "effect"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
import { testEffect } from "./lib/effect"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { testEffect } from "../lib/effect"
const contribution = (key: string, text: string, sourceKey = key) => ({
key: SystemContext.Key.make(key),

View file

@ -38,7 +38,9 @@ describe("SkillTool", () => {
location: AbsolutePath.make(location),
content: "# Effect\n\nGuidance",
}
let current = [info]
const assertions: PermissionV2.AssertInput[] = []
let deny = false
const truncations: ToolOutputStore.TruncateInput[] = []
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
@ -55,7 +57,10 @@ describe("SkillTool", () => {
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) => Effect.sync(() => assertions.push(input)),
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
@ -68,8 +73,7 @@ describe("SkillTool", () => {
SkillV2.Service.of({
transform: () => Effect.die("unused"),
sources: () => Effect.die("unused"),
list: () => Effect.succeed([info]),
forAgent: () => Effect.die("unused"),
list: () => Effect.succeed(current),
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
@ -97,7 +101,7 @@ describe("SkillTool", () => {
expect(bootWaited).toBe(true)
expect((yield* registry.definitions())[0]).toMatchObject({
name: "skill",
description: expect.stringContaining("**effect**: Use Effect"),
description: SkillTool.description,
})
expect(
yield* registry.execute({
@ -141,7 +145,35 @@ describe("SkillTool", () => {
sessionID,
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } },
}),
).toEqual({ type: "error", value: 'Skill "missing" not found. Available skills: effect' })
).toEqual({ type: "error", value: "Unable to load skill missing" })
deny = true
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } },
}),
).toEqual({ type: "error", value: "Unable to load skill effect" })
deny = false
const flat = new SkillV2.Info({
name: "public",
description: "Public guidance",
location: AbsolutePath.make(path.join(tmp.path, "public.md")),
content: "Public",
})
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(flat.location, "public"),
fs.writeFile(path.join(tmp.path, "secret.md"), "secret"),
]),
)
current = [flat]
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } },
}),
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
}).pipe(Effect.provide(layer))
}),
),