refactor(core): bind standalone skill activation to Session (#46077)

Move standalone skill activation into ID-bound Session handles and delegate from the public service. Preserve current-placement lookup, raw skill content, ambient publication context, validation order, and host-scoped detached resume behavior. Cover ownership and lifecycle contracts with focused regressions.
This commit is contained in:
Kit Langton 2026-08-30 10:22:03 -04:00 committed by GitHub
parent b1e3a7b222
commit 4a977b2b31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 220 additions and 40 deletions

View file

@ -1,7 +1,7 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, desc, eq } from "drizzle-orm"
import { Project } from "./project.js"
@ -50,8 +50,6 @@ import { Session } from "./session/session.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { PluginSupervisor } from "./plugin/supervisor-service.js"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { Event } from "@opencode-ai/schema/event"
import { Skill } from "./skill.js"
import { Job } from "./job.js"
import { Command } from "./command.js"
import { Global } from "@opencode-ai/util/global"
@ -202,12 +200,9 @@ export interface Interface {
readonly shell: (
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["shell"]>
readonly skill: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
skill: Skill.ID
resume?: boolean
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
readonly skill: (
input: Parameters<Session.Handle["skill"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["skill"]>
readonly compact: (
input: CompactInput,
) => Effect.Effect<SessionInbox.Compaction, NotFoundError | CompactionConflictError>
@ -247,7 +242,6 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const scope = yield* Scope.Scope
const sessions = yield* Session.make((ref) => locations.get(ref))
const admission = yield* SessionInbox.Service
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
@ -435,26 +429,7 @@ const layer = Layer.effect(
})
}),
shell: (input) => sessions.forSession(input.sessionID).shell(input),
skill: Effect.fn("Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const skill = yield* skills.get(input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* bus.publish(
SessionEvent.Skill.Activated,
{
sessionID: input.sessionID,
id: skill.id,
name: skill.name,
text: skill.content,
},
{ id: input.id ? Event.ID.make(input.id.replace(/^msg_/, "evt_")) : undefined },
)
if (input.resume !== false)
yield* execution
.resume(input.sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
skill: (input) => sessions.forSession(input.sessionID).skill(input),
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
rename: (input) => sessions.forSession(input.sessionID).rename(input),

View file

@ -9,6 +9,7 @@ import { Location } from "../location.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { Shell } from "../shell.js"
import { ShellResult } from "../shell/result.js"
import { Skill } from "../skill.js"
import {
BusyError,
CompactionConflictError,
@ -19,6 +20,7 @@ import {
MessageToolIncompleteError,
NotFoundError,
PromptConflictError,
SkillNotFoundError,
SyntheticConflictError,
} from "./error.js"
import { SessionEvent } from "./event.js"
@ -30,7 +32,12 @@ import { SessionRevert } from "./revert.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
export type Services = PluginSupervisor.Service | SessionPrompt.Service | SessionRevert.Service | Shell.Service
export type Services =
| PluginSupervisor.Service
| SessionPrompt.Service
| SessionRevert.Service
| Shell.Service
| Skill.Service
type PromptRequest = SessionPrompt.Input & {
id?: SessionMessage.ID
@ -238,6 +245,29 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
yield* Fiber.join(running)
})
const skill = Effect.fn("Session.skill")(function* (
sessionID: SessionSchema.ID,
input: { id?: SessionMessage.ID; skill: Skill.ID; resume?: boolean },
) {
const session = yield* get(sessionID)
const skills = yield* Skill.Service.pipe(Effect.provide(servicesFor(session.location)))
const skill = yield* skills.get(input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* bus.publish(
SessionEvent.Skill.Activated,
{
sessionID,
id: skill.id,
name: skill.name,
text: skill.content,
},
{ id: input.id ? Event.ID.make(input.id.replace(/^msg_/, "evt_")) : undefined },
)
if (input.resume !== false)
yield* execution
.resume(sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
})
const compact = Effect.fn("Session.compact")(function* (
sessionID: SessionSchema.ID,
input: { id?: SessionMessage.ID; delivery?: SessionInbox.Delivery },
@ -346,6 +376,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
prompt,
synthetic,
shell,
skill,
compact,
wait,
resume,
@ -368,6 +399,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
const prompt = operations.prompt.bind(undefined, sessionID)
const synthetic = operations.synthetic.bind(undefined, sessionID)
const shell = operations.shell.bind(undefined, sessionID)
const skill = operations.skill.bind(undefined, sessionID)
const compact = operations.compact.bind(undefined, sessionID)
const wait = operations.wait.bind(undefined, sessionID)
const resume = operations.resume.bind(undefined, sessionID)
@ -393,6 +425,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
prompt,
synthetic,
shell,
skill,
compact,
wait,
resume,

View file

@ -3,7 +3,6 @@ import { and, eq } from "drizzle-orm"
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Event } from "@opencode-ai/schema/event"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Money } from "@opencode-ai/schema/money"
import { Project } from "@opencode-ai/schema/project"
@ -16,6 +15,7 @@ import { Bus } from "../src/bus.js"
import { Database } from "../src/database/database.js"
import { EventTable } from "../src/event/sql.js"
import { Image } from "../src/image.js"
import { Location } from "../src/location.js"
import { PluginHooks } from "../src/plugin/hooks.js"
import { PluginSupervisor } from "../src/plugin/supervisor-service.js"
import { ProjectTable } from "../src/project/sql.js"
@ -37,6 +37,7 @@ import { Shell } from "../src/shell.js"
import { Skill } from "../src/skill.js"
import { Snapshot } from "../src/snapshot.js"
import { tempGlobalLayer } from "./fixture/global"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(
@ -58,10 +59,18 @@ const it = testEffect(
const sessionID = SessionSchema.ID.make("ses_owned")
const otherID = SessionSchema.ID.make("ses_owned_other")
const source = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const skillInfo = Skill.Info.make({
id: Skill.ID.make("guide"),
name: Skill.Name.make("Guide"),
description: "Session guidance",
location: AbsolutePath.make("/skills/guide/SKILL.md"),
content: " Raw guidance\n",
})
const setup = Effect.fnUntraced(function* (options?: {
execution?: SessionExecution.Interface
shell?: Layer.Layer<Shell.Service>
skills?: (ref: Location.Ref) => Layer.Layer<Skill.Service>
snapshot?: (ref: Location.Ref) => Layer.Layer<Snapshot.Service>
}) {
const database = yield* Database.Service
@ -86,11 +95,15 @@ const setup = Effect.fnUntraced(function* (options?: {
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(LayerNode.compile(PluginHooks.node)))
const locations: Location.Ref[] = []
const flushes: Location.Ref[] = []
const resumes: SessionSchema.ID[] = []
const wakes: Array<{ sessionID: SessionSchema.ID; pending: SessionMessage.ID[]; enqueued: number }> = []
const execution = SessionExecution.Service.of({
active: Effect.succeed(new Set<SessionSchema.ID>()),
isActive: () => Effect.succeed(false),
resume: () => Effect.void,
resume: (id) =>
Effect.sync(() => {
resumes.push(id)
}),
awaitIdle: () => Effect.void,
interrupt: () => Effect.succeed(false),
wake: (id) =>
@ -113,7 +126,6 @@ const setup = Effect.fnUntraced(function* (options?: {
const services = Layer.mergeAll(
Layer.succeed(PluginHooks.Service, hooks),
Layer.mock(Image.Service, {}),
Layer.mock(Skill.Service, {}),
options?.shell ?? Layer.mock(Shell.Service, {}),
)
const servicesFor = (ref: Location.Ref): Layer.Layer<Session.Services> => {
@ -122,6 +134,11 @@ const setup = Effect.fnUntraced(function* (options?: {
Layer.provideMerge(
Layer.mergeAll(
services,
Layer.succeed(Location.Service, location(ref)),
options?.skills?.(ref) ??
Layer.mock(Skill.Service, {
get: (id) => Effect.succeed(id === skillInfo.id ? skillInfo : undefined),
}),
options?.snapshot?.(ref) ?? Layer.mock(Snapshot.Service, {}),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.sync(() => {
@ -146,7 +163,7 @@ const setup = Effect.fnUntraced(function* (options?: {
>(),
Effect.provideService(SessionExecution.Service, options?.execution ?? execution),
)
return { sessions, hooks, locations, flushes, wakes, db: database.db, bus, store }
return { sessions, hooks, locations, flushes, resumes, wakes, db: database.db, bus, store }
})
describe("Session-owned handles", () => {
@ -339,6 +356,144 @@ describe("Session-owned handles", () => {
}),
)
it.live("activates skills through detached handles using fresh placement and ambient publication context", () =>
Effect.gen(function* () {
const fixture = yield* setup({
skills: (ref) =>
Layer.mock(Skill.Service, { get: () => Effect.succeed({ ...skillInfo, content: ref.directory }) }),
})
const handle = fixture.sessions.forSession(sessionID)
const { skill } = handle
const events: Event.Payload[] = []
yield* fixture.bus.listen((event) =>
Effect.sync(() => {
events.push(event)
}),
)
const initial = SessionMessage.ID.make("msg_owned_skill_initial")
yield* skill({ id: initial, skill: skillInfo.id, resume: false }).pipe(
Effect.satisfiesServicesType<never>(),
Effect.setContext(Context.empty()),
)
const moved = SessionMessage.ID.make("msg_owned_skill_moved")
const activation = skill({ id: moved, skill: skillInfo.id, resume: false })
const destination = Location.Ref.make({ directory: AbsolutePath.make("/project/moved") })
yield* fixture.bus.publish(SessionEvent.Moved, {
sessionID,
location: destination,
projectID: Project.ID.global,
subpath: RelativePath.make("moved"),
})
yield* activation.pipe(Effect.satisfiesServicesType<never>(), Effect.setContext(Context.empty()))
yield* skill({ skill: skillInfo.id, resume: false }).pipe(
Effect.provideService(Location.Service, location(source)),
)
expect(fixture.locations).toEqual([source, destination, destination])
expect(yield* handle.message(initial)).toMatchObject({ type: "skill", text: source.directory })
expect(yield* handle.message(moved)).toMatchObject({ type: "skill", text: destination.directory })
expect(
events.filter((event) => event.type === SessionEvent.Skill.Activated.type).map((event) => event.location),
).toEqual([undefined, undefined, source])
expect(fixture.flushes).toEqual([])
expect(fixture.resumes).toEqual([])
expect(fixture.wakes).toEqual([])
}),
)
it.live("checks Session existence before skill lookup and leaves missing activations untouched", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const events: Event.Payload[] = []
yield* fixture.bus.listen((event) =>
Effect.sync(() => {
events.push(event)
}),
)
const missingID = SessionSchema.ID.make("ses_missing_skill")
expect(
yield* fixture.sessions.forSession(missingID).skill({ skill: skillInfo.id }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.NotFoundError", sessionID: missingID })
expect(fixture.locations).toEqual([])
const handle = fixture.sessions.forSession(sessionID)
const before = yield* handle.get()
const missing = Skill.ID.make("missing")
expect(yield* handle.skill({ skill: missing }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.SkillNotFoundError",
skill: missing,
})
expect(fixture.locations).toEqual([source])
expect(events).toEqual([])
expect(yield* handle.get()).toEqual(before)
expect(yield* handle.inbox()).toEqual([])
expect(yield* fixture.store.context(sessionID)).toEqual([])
expect(fixture.flushes).toEqual([])
expect(fixture.resumes).toEqual([])
expect(fixture.wakes).toEqual([])
}),
)
it.live("publishes skills before detached resumes and owns those resumes in the host scope", () =>
Effect.gen(function* () {
const scope = yield* Scope.Scope
const host = yield* Scope.fork(scope, "sequential")
const calls: string[] = []
const stopped: SessionSchema.ID[] = []
const execution = yield* SessionExecution.Service.pipe(Effect.provide(SessionExecution.noopLayer))
const fixture = yield* setup({
execution: {
...execution,
resume: (id) =>
Effect.gen(function* () {
calls.push(`resume:${id}`)
yield* Effect.never
}).pipe(
Effect.onInterrupt(() =>
Effect.sync(() => {
stopped.push(id)
}),
),
),
wake: () =>
Effect.sync(() => {
calls.push("wake")
}),
},
}).pipe(Scope.provide(host))
yield* fixture.bus.listen((event) =>
Effect.sync(() => {
if (event.type === SessionEvent.Skill.Activated.type) calls.push(`published:${event.id}`)
}),
)
const { skill } = fixture.sessions.forSession(sessionID)
yield* skill({ id: SessionMessage.ID.make("msg_skill_no_resume"), skill: skillInfo.id, resume: false })
expect(calls).toEqual(["published:evt_skill_no_resume"])
yield* Effect.forEach(
[
{ id: SessionMessage.ID.make("msg_skill_default_resume"), skill: skillInfo.id },
{ id: SessionMessage.ID.make("msg_skill_explicit_resume"), skill: skillInfo.id, resume: true },
],
(input) => skill(input).pipe(Effect.scoped, Effect.forkScoped, Effect.flatMap(Fiber.join)),
)
expect(calls).toEqual([
"published:evt_skill_no_resume",
"published:evt_skill_default_resume",
`resume:${sessionID}`,
"published:evt_skill_explicit_resume",
`resume:${sessionID}`,
])
expect(stopped).toEqual([])
yield* Scope.close(host, Exit.void)
expect(stopped).toEqual([sessionID, sessionID])
expect(yield* fixture.sessions.forSession(sessionID).inbox()).toEqual([])
}),
)
it.live("keeps prompt wakes independent of shell work across handles", () =>
Effect.gen(function* () {
const blocked = yield* Deferred.make<void>()

View file

@ -17,12 +17,14 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionPrompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { Skill } from "@opencode-ai/core/skill"
import { Event } from "@opencode-ai/schema/event"
import { testEffect } from "./lib/effect"
import { globalProjectNode } from "./lib/project"
@ -32,7 +34,7 @@ const info = Skill.Info.make({
name: Skill.Name.make("Effect"),
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect.md")),
content: "Use Effect",
content: " Use Effect\n",
})
const locations = makeGlobalNode({
service: LocationServiceMap.Service,
@ -146,17 +148,32 @@ describe("Session.skill", () => {
}),
)
it.effect("projects the caller-supplied message ID", () =>
it.effect("publishes raw standalone content under the caller-supplied ID without inbox admission", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const bus = yield* Bus.Service
const session = yield* sessions.create({ location })
const id = SessionMessage.ID.make("msg_caller_skill")
const events: Event.Payload[] = []
yield* bus.listen((event) =>
Effect.sync(() => {
events.push(event)
}),
)
yield* sessions.skill({ id, sessionID: session.id, skill: Skill.ID.make("effect"), resume: false })
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: "Use Effect" }),
)
expect(events).toEqual([
expect.objectContaining({
id: "evt_caller_skill",
type: SessionEvent.Skill.Activated.type,
data: { sessionID: session.id, id: info.id, name: info.name, text: info.content },
}),
])
expect(yield* sessions.messages({ sessionID: session.id })).toEqual([
expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: info.content }),
])
expect(yield* sessions.inbox(session.id)).toEqual([])
}),
)
})