feat(core): admit v2 skill guidance (#30843)
This commit is contained in:
parent
cc487dd032
commit
3f64b5e621
40 changed files with 3119 additions and 174 deletions
|
|
@ -51,7 +51,6 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
|||
transform,
|
||||
sources: () => Effect.succeed(sources),
|
||||
list: () => Effect.succeed([]),
|
||||
forAgent: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" }])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
154
packages/core/test/skill/guidance.test.ts
Normal file
154
packages/core/test/skill/guidance.test.ts
Normal 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])))
|
||||
})
|
||||
})
|
||||
|
|
@ -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"))
|
||||
|
|
@ -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: {
|
||||
|
|
@ -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),
|
||||
|
|
@ -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))
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue