diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index fd39b5cd470..0ffac276d23 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -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[0] & { sessionID: SessionSchema.ID }, ) => ReturnType - readonly skill: (input: { - id?: SessionMessage.ID - sessionID: SessionSchema.ID - skill: Skill.ID - resume?: boolean - }) => Effect.Effect + readonly skill: ( + input: Parameters[0] & { sessionID: SessionSchema.ID }, + ) => ReturnType readonly compact: ( input: CompactInput, ) => Effect.Effect @@ -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), diff --git a/packages/core/src/session/session.ts b/packages/core/src/session/session.ts index 8bc602dcb1e..55a90efd2c8 100644 --- a/packages/core/src/session/session.ts +++ b/packages/core/src/session/session.ts @@ -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, diff --git a/packages/core/test/session-owned.test.ts b/packages/core/test/session-owned.test.ts index 36d70cc4b62..67dd91d5a66 100644 --- a/packages/core/test/session-owned.test.ts +++ b/packages/core/test/session-owned.test.ts @@ -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 + skills?: (ref: Location.Ref) => Layer.Layer snapshot?: (ref: Location.Ref) => Layer.Layer }) { 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()), 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 => { @@ -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(), + 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(), 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() diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts index 83b99acdd1f..3edcb76b805 100644 --- a/packages/core/test/session-skill.test.ts +++ b/packages/core/test/session-skill.test.ts @@ -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([]) }), ) })