Compare commits

...
29 changed files with 322 additions and 91 deletions
+10 -7
View File
@@ -27,6 +27,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
app: ${{ steps.packages.outputs.app }}
cli: ${{ steps.packages.outputs.cli }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
@@ -42,18 +43,20 @@ jobs:
- name: Find affected packages
id: packages
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "app=true" >> "$GITHUB_OUTPUT"
echo "cli=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
bun x turbo@2.10.2 ls --affected --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); for (const name of ["app", "cli"]) console.log(`${name}=${result.packages.items.some((item) => item.name === `@opencode-ai/${name}`)}`)' >> "$GITHUB_OUTPUT"
unit:
name: unit (${{ matrix.settings.name }})
needs: affected
strategy:
fail-fast: false
matrix:
@@ -119,7 +122,7 @@ jobs:
GITHUB_ACTIONS=false bun turbo test --affected
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Verify published codemode package
@@ -137,7 +140,7 @@ jobs:
fi
bun turbo verify:package --affected --filter=@opencode-ai/sdk
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Verify compiled service lifecycle
@@ -151,13 +154,13 @@ jobs:
bun run script/service-smoke.ts
- name: Setup Node build runtime
if: always()
if: needs.affected.outputs.cli == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- name: Verify Node build
if: always()
if: needs.affected.outputs.cli == 'true'
timeout-minutes: 15
working-directory: packages/cli
env:
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-fOM/kGJJ1cipCHQIxioDZEB7NZykpSiqgwm7gIS6THI=",
"aarch64-linux": "sha256-XTY2C33HjsBMWO7VIiWc2MynjJrxbTLrOJ+6pM+afI0=",
"aarch64-darwin": "sha256-wX6+bC18djtPZ7A9ch+wryM7tDFfrAlT0xx0QTk6EJQ=",
"x86_64-darwin": "sha256-dcRRX4bYq5AmG4GcVmYq/M+06dlf4KJHn+clT2JY48g="
"x86_64-linux": "sha256-oQnV96kE3lIqsQaaUrH4tiEX8/5xvBWizXMGxayFSHo=",
"aarch64-linux": "sha256-Hkl1xdCQ7voAllwtyrm3TjQT9cfej29fvPIP3lH7zVo=",
"aarch64-darwin": "sha256-s0WHRB13qcD0KlgWNXO7gLpsDOnfB8xny81GnN5YeQc=",
"x86_64-darwin": "sha256-fnYi1AxCrnO3byW9keDfBH2ueCfGZdaweOrV6AerrGs="
}
}
@@ -476,7 +476,7 @@ export type PromptFileAttachment = {
export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text?: string; mention?: PromptMention }
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
@@ -2740,6 +2740,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -3015,6 +3016,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -3290,6 +3292,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
+1 -1
View File
@@ -108,7 +108,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const source =
+22 -12
View File
@@ -596,7 +596,11 @@ const layer = Layer.effect(
yield* plugins.flush
return yield* Image.Service
}).pipe(Effect.provide(locations.get(session.location)))
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const skills = Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Skill.Service
}).pipe(Effect.provide(locations.get(session.location)))
const prompt = yield* resolvePrompt(
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
image,
@@ -718,7 +722,7 @@ const layer = Layer.effect(
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.list()).find((item) => item.id === input.skill)
const skill = yield* skills.get(input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* bus.publish(
SessionEvent.Skill.Activated,
@@ -970,16 +974,22 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
const selected = yield* Effect.gen(function* () {
if (!requested?.length) return undefined
const skillService = yield* skills
const available = yield* skillService.list()
return yield* Effect.forEach(requested, (attachment) => {
const skill = available.find((item) => item.id === attachment.id)
if (!skill) return Effect.fail(new SkillNotFoundError({ skill: attachment.id }))
return Effect.succeed({
id: skill.id,
name: skill.name,
mention: attachment.mention,
})
})
const prepared = new Map<Skill.ID, Skill.Name>()
return yield* Effect.forEach(requested, (attachment) =>
Effect.gen(function* () {
const name = prepared.get(attachment.id)
if (name !== undefined) return { id: attachment.id, name, mention: attachment.mention }
const skill = yield* skillService.get(attachment.id)
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
prepared.set(skill.id, skill.name)
return {
id: skill.id,
name: skill.name,
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
mention: attachment.mention,
}
}),
)
})
return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined })
})
+5 -1
View File
@@ -139,7 +139,11 @@ const serialize = (message: SessionMessage.Info) => {
(file) =>
`[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`,
) ?? []
return [`[User]: ${message.text}`, ...files].join("\n")
const skills =
message.skills?.flatMap((skill) =>
skill.text === undefined ? [] : [`[Skill activated: ${skill.name}]\n${skill.text}`],
) ?? []
return [...skills, `[User]: ${message.text}`, ...files].join("\n")
}
if (message.type === "location-switched")
return `[User]: The working directory has been changed to ${message.location.directory}.`
@@ -236,6 +236,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
]
case "user":
const content = [
...(message.skills ?? []).flatMap((skill) => (skill.text === undefined ? [] : [Message.text(skill.text)])),
...(message.text === "" ? [] : [Message.text(message.text)]),
...userAttachmentContent(message.files ?? []),
]
+1
View File
@@ -220,6 +220,7 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
skills: message.skills?.map((skill, index) => ({
...skill,
name: Skill.Name.make(redact("skill-name", String(index), skill.name)),
text: skill.text === undefined ? undefined : redact("skill", String(index), skill.text),
mention: skill.mention
? { ...skill.mention, text: redact("skill-mention", String(index), skill.mention.text) }
: undefined,
+20
View File
@@ -1,6 +1,7 @@
export * as Skill from "./skill.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { FSUtil } from "@opencode-ai/util/fs-util"
import path from "path"
import { Context, Effect, Layer, Types } from "effect"
import { Skill } from "@opencode-ai/schema/skill"
@@ -52,6 +53,21 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
].join("\n")
}
export const prepare = Effect.fn("Skill.prepare")(function* (fs: FSUtil.Interface, skill: Info) {
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
.filter((file) => path.basename(file) !== "SKILL.md")
.toSorted()
.slice(0, 10)
: []
return {
directory,
output: toModelOutput(skill, files),
}
})
export type Data = {
skills: Map<ID, Types.DeepMutable<Info>>
}
@@ -64,6 +80,7 @@ export type Draft = {
}
export interface Interface extends State.Transformable<Draft> {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
@@ -98,6 +115,9 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
get: Effect.fn("Skill.get")(function* (id) {
return state.get().skills.get(id)
}),
list: Effect.fn("Skill.list")(function* () {
return Array.from(state.get().skills.values())
}),
-1
View File
@@ -26,7 +26,6 @@ const render = (skills: ReadonlyArray<Summary>) =>
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"When the user references a skill with @skill-id, load that skill with the skill tool.",
...(skills.length === 0
? ["No skills are currently available."]
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
+2 -17
View File
@@ -1,7 +1,6 @@
export * as SkillTool from "./skill.js"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -9,7 +8,6 @@ import { Skill } from "../../skill.js"
import { Permission } from "../../permission.js"
export const name = "skill"
const FILE_LIMIT = 10
export const Input = Schema.Struct({
id: Skill.ID.annotate({ description: "The ID of an available skill or a skill explicitly referenced by the user" }),
@@ -47,8 +45,7 @@ export const Plugin = {
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const current = yield* skills.list()
const skill = current.find((skill) => skill.id === input.id)
const skill = yield* skills.get(input.id)
if (!skill) return yield* unableToLoad(input.id)
return yield* Effect.gen(function* () {
yield* permission.assert({
@@ -59,19 +56,7 @@ export const Plugin = {
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const directory = path.dirname(skill.location)
const files =
path.basename(skill.location) === "SKILL.md"
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
.filter((file) => path.basename(file) !== "SKILL.md")
.toSorted()
.slice(0, FILE_LIMIT)
: []
return {
name: skill.name,
directory,
output: Skill.toModelOutput(skill, files),
}
return { name: skill.name, ...(yield* Skill.prepare(fs, skill)) }
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
}).pipe(
Effect.map((output) => ({
+42
View File
@@ -1,5 +1,6 @@
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -221,6 +222,47 @@ describe("Npm.add", () => {
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
).toBeTruthy()
})
test("refreshes mutable Git packages once per service lifetime and preserves pinned or cached installs", async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
const repository = pathToFileURL(fixture.repository).href
const mutable = `git+${repository}#fixture-branch`
const pinned = `git+${repository}#${fixture.commit}`
const first = await Effect.gen(function* () {
const npm = yield* Npm.Service
const mutableEntry = yield* npm.add(mutable, { refresh: true })
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
yield* Effect.promise(async () => {
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
await Bun.$`git -C ${fixture.repository} add .`
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
})
yield* npm.add(mutable, { refresh: true })
return { mutable: mutableEntry, pinned: pinnedEntry }
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(first.mutable.directory, "index.js")).text()).toContain("root: true")
expect(await Bun.file(path.join(first.pinned.directory, "index.js")).text()).toContain("root: true")
const second = await Effect.gen(function* () {
const npm = yield* Npm.Service
return {
mutable: yield* npm.add(mutable, { refresh: true }),
pinned: yield* npm.add(pinned, { refresh: true }),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(second.mutable.directory, "index.js")).text()).toContain('root: "second"')
expect(await Bun.file(path.join(second.pinned.directory, "index.js")).text()).toContain("root: true")
await fs.rename(fixture.repository, `${fixture.repository}-offline`)
const offline = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(mutable, { refresh: true })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
})
})
describe("Npm.resolve", () => {
@@ -23,6 +23,7 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { Skill } from "@opencode-ai/schema/skill"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -231,6 +232,13 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
id: SessionMessage.ID.create(),
type: "user" as const,
text: "Manual compaction should include this short conversation.",
skills: [
{
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
text: "Use Effect services and generators.",
},
],
time: { created: DateTime.makeUnsafe(0) },
}
const session = yield* insertSession(sessionID, { parent_id: parentID })
@@ -261,6 +269,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
})
expect(requests[0]?.generation).toBeUndefined()
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
])
+13 -1
View File
@@ -5,6 +5,7 @@ import path from "path"
import { DateTime, Effect, Layer, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { Shell } from "@opencode-ai/schema/shell"
import { Skill } from "@opencode-ai/schema/skill"
import { Agent } from "@opencode-ai/core/agent"
import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
@@ -1178,6 +1179,13 @@ describe("SessionTransfer", () => {
id: sourceMessageID,
type: "user",
text: "Imported message",
skills: [
{
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
text: "Private skill instructions from /private/project",
},
],
time: { created: DateTime.makeUnsafe(100) },
},
{
@@ -1207,7 +1215,11 @@ describe("SessionTransfer", () => {
const sanitized = yield* transfer.export({ sessionID, sanitize: true })
expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(sanitized.messages).toMatchObject([
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
{
id: sourceMessageID,
text: `[redacted:text:${sourceMessageID}]`,
skills: [{ id: "effect", name: "[redacted:skill-name:0]", text: "[redacted:skill:0]" }],
},
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } },
])
@@ -205,6 +205,44 @@ Recent work
})
})
test("lowers each prepared skill once before the prompt", () => {
const effect = SkillAttachment.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
text: "<skill_content>Use Effect</skill_content>",
})
const api = SkillAttachment.make({
id: Skill.ID.make("api-design"),
name: Skill.Name.make("API design"),
text: "<skill_content>Design APIs</skill_content>",
})
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-skill-content"),
type: "user",
text: "Use @effect and @api-design",
skills: [effect, api, SkillAttachment.make({ id: effect.id, name: effect.name })],
time: { created },
}),
],
model,
)
expect(messages).toEqual([
Message.make({
id: id("user-skill-content"),
role: "user",
content: [
{ type: "text", text: "<skill_content>Use Effect</skill_content>" },
{ type: "text", text: "<skill_content>Design APIs</skill_content>" },
{ type: "text", text: "Use @effect and @api-design" },
],
metadata: {},
}),
])
})
test("does not inject skill content for reference-only attachments", () => {
const messages = toLLMMessages(
[
+57 -17
View File
@@ -9,6 +9,7 @@ import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -23,18 +24,20 @@ const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const projects = Layer.mock(Project.Service, {
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
const skills = Layer.mock(Skill.Service, {
list: () =>
Effect.succeed([
Skill.Info.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
content: "Use Effect",
}),
]),
const info = Skill.Info.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
description: "Effect guidance",
location: AbsolutePath.make(path.resolve("/skills/effect.md")),
content: "Use Effect",
})
const skills = Layer.merge(
Layer.mock(Skill.Service, {
get: (id) => Effect.succeed(id === info.id ? info : undefined),
list: () => Effect.succeed([info]),
}),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
)
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
@@ -56,7 +59,7 @@ const it = testEffect(
)
describe("Session.skill", () => {
it.effect("keeps skill mentions as references on a normal prompt", () =>
it.effect("materializes mentioned skills on their owning prompt", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const database = yield* Database.Service
@@ -67,26 +70,63 @@ describe("Session.skill", () => {
yield* sessions.prompt({
id,
sessionID: session.id,
text: "Apply @effect",
skills: [{ id: Skill.ID.make("effect"), mention: { start: 6, end: 13, text: "@effect" } }],
text: "Apply @effect and @effect",
skills: [
{ id: Skill.ID.make("effect"), mention: { start: 6, end: 13, text: "@effect" } },
{ id: Skill.ID.make("effect"), mention: { start: 18, end: 25, text: "@effect" } },
],
resume: false,
})
expect(yield* sessions.messages({ sessionID: session.id })).toEqual([])
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
expect(yield* sessions.messages({ sessionID: session.id })).toEqual([
expect.objectContaining({
id,
type: "user",
text: "Apply @effect",
text: "Apply @effect and @effect",
skills: [
{
id: "effect",
name: "Effect",
text: Skill.toModelOutput(info, []),
mention: { start: 6, end: 13, text: "@effect" },
},
{
id: "effect",
name: "Effect",
mention: { start: 18, end: 25, text: "@effect" },
},
],
}),
)
])
}),
)
it.effect("excludes mentioned skills when forking before their prompt", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const session = yield* sessions.create({ location })
const initial = SessionMessage.ID.make("msg_before_skill_attachment")
const selected = SessionMessage.ID.make("msg_fork_skill_attachment")
yield* sessions.prompt({ id: initial, sessionID: session.id, text: "Before the skill", resume: false })
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
yield* sessions.prompt({
id: selected,
sessionID: session.id,
text: "Apply @effect",
skills: [{ id: info.id, mention: { start: 6, end: 13, text: "@effect" } }],
resume: false,
})
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
const forked = yield* sessions.fork({ sessionID: session.id, boundary: { type: "before", messageID: selected } })
expect(yield* sessions.messages({ sessionID: forked.id })).toEqual([
expect.objectContaining({ type: "user", text: "Before the skill" }),
])
}),
)
+2
View File
@@ -31,6 +31,8 @@ describe("Skill", () => {
})
expect(yield* skill.list()).toEqual([info("review", "Second"), info("deploy", "Deploy")])
expect(yield* skill.get(Skill.ID.make("review"))).toEqual(info("review", "Second"))
expect(yield* skill.get(Skill.ID.make("missing"))).toBeUndefined()
}),
)
@@ -59,7 +59,6 @@ describe("SkillInstructions", () => {
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"When the user references a skill with @skill-id, load that skill with the skill tool.",
"<available_skills>",
" <skill>",
" <id>effect</id>",
+1
View File
@@ -74,6 +74,7 @@ describe("SkillTool", () => {
Skill.Service.of({
transform: (_transform) => Effect.die("unused"),
reload: () => Effect.die("unused"),
get: (id) => Effect.succeed(current.find((skill) => skill.id === id)),
list: () => Effect.succeed(current),
}),
)
+3
View File
@@ -16200,6 +16200,9 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
+1
View File
@@ -57,6 +57,7 @@ export interface SkillAttachment extends Schema.Schema.Type<typeof SkillAttachme
export const SkillAttachment = Schema.Struct({
id: Skill.ID,
name: Skill.Name,
text: Schema.String.pipe(optional),
mention: PromptMention.pipe(optional),
}).annotate({ identifier: "Prompt.SkillAttachment" })
@@ -6,6 +6,7 @@ import { Form } from "../src/form.js"
import { Mcp } from "../src/mcp.js"
import { Model } from "../src/model.js"
import { Project } from "../src/project.js"
import { SkillAttachment } from "../src/prompt.js"
import { Provider } from "../src/provider.js"
import { Pty } from "../src/pty.js"
import { Session } from "../src/session.js"
@@ -79,6 +80,16 @@ describe("contract hygiene", () => {
).toEqual({ created: 0, updated: 0, idle: 2, viewed: 1 })
})
test("skill attachments retain legacy references while accepting prepared instructions", () => {
const reference = { id: Skill.ID.make("effect"), name: Skill.Name.make("Effect") }
expect(Schema.decodeUnknownSync(SkillAttachment)(reference)).toEqual(reference)
expect(Schema.encodeSync(SkillAttachment)({ ...reference, text: undefined })).toEqual(reference)
expect(Schema.decodeUnknownSync(SkillAttachment)({ ...reference, text: "Use Effect" })).toEqual({
...reference,
text: "Use Effect",
})
})
test("session inbox items omit the internal enqueue sequence", () => {
expect(
Schema.encodeSync(SessionInbox.Info)(
+21 -13
View File
@@ -190,6 +190,9 @@ function pendingPrompt(item: SessionInboxInfo): FooterQueuedPrompt | undefined {
messageID: item.id,
prompt: { messageID: item.id, text: item.payload.text, parts: [] },
delivery: item.delivery,
...(item.payload.skills?.length
? { skills: item.payload.skills.map((skill) => ({ id: skill.id, name: skill.name })) }
: {}),
}
}
@@ -388,6 +391,12 @@ function skillCommit(messageID: string, name: string, skillID = messageID): Stre
}
}
function skillCommits(messageID: string, skills: FooterQueuedPrompt["skills"] = []) {
return Array.from(new Map(skills.map((skill) => [skill.id, skill])).values(), (skill) =>
skillCommit(messageID, skill.name, skill.id),
)
}
function compactionCommit(messageID: string): StreamCommit {
return {
kind: "system",
@@ -667,7 +676,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (!render) return
if (reuseVisibleWait && waiting) return
write([
...(message.skills ?? []).map((skill) => skillCommit(message.id, skill.name, skill.id)),
...skillCommits(message.id, message.skills),
{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id },
])
return
@@ -956,18 +965,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
syncPending()
const visible = state.messageIDs.has(event.data.inboxID)
if (waiting || pending) state.messageIDs.add(event.data.inboxID)
if (!waiting && pending && !visible) {
write([
{
kind: "user",
source: "system",
text: pending.prompt.text,
phase: "start",
messageID: event.data.inboxID,
},
])
}
write([], { phase: "running", status: "waiting for assistant" })
const commits = pending && !visible ? skillCommits(event.data.inboxID, pending.skills) : []
if (!waiting && pending && !visible)
commits.push({
kind: "user",
source: "system",
text: pending.prompt.text,
phase: "start",
messageID: event.data.inboxID,
})
write(commits, { phase: "running", status: "waiting for assistant" })
return
}
if (event.type === "session.inbox.delivery.changed") {
@@ -979,6 +986,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (state.messageIDs.has(event.data.inboxID)) return
state.messageIDs.add(event.data.inboxID)
write([
...skillCommits(event.data.inboxID, pending.skills),
{
kind: "user",
source: "system",
+1
View File
@@ -93,6 +93,7 @@ export type FooterQueuedPrompt = {
messageID: string
prompt: RunPrompt
delivery: RunDelivery
skills?: ReadonlyArray<{ id: string; name: string }>
}
export type QueuedPromptAction = "steer" | "cancel"
@@ -667,7 +667,13 @@ describe("V2 mini transport", () => {
sessionID: "ses_1",
timeCreated: 1,
type: "user",
payload: { text: "follow up" },
payload: {
text: "follow up",
skills: [
{ id: "effect", name: "Effect", text: "Use Effect services" },
{ id: "effect", name: "Effect" },
],
},
delivery: "queue",
},
{
@@ -706,9 +712,10 @@ describe("V2 mini transport", () => {
})
while (!ui.commits.some((item) => item.messageID === "msg_queued")) await Bun.sleep(0)
expect(ui.commits).toContainEqual(
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
)
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toEqual([
expect.objectContaining({ kind: "system", partID: "skill:effect", text: '→ Skill "Effect"' }),
expect.objectContaining({ kind: "user", text: "follow up" }),
])
expect(pending()).toEqual([["msg_cancelled", "queue"]])
events.push({
id: "evt_queued",
@@ -739,7 +746,7 @@ describe("V2 mini transport", () => {
data: { sessionID: "ses_1", inboxID: "msg_queued" },
})
while (pending()?.length !== 0) await Bun.sleep(0)
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(1)
expect(ui.commits.filter((item) => item.messageID === "msg_queued")).toHaveLength(2)
const prompt = spyOn(client.session, "prompt").mockImplementation(
(request) => ok(promptAdmission(request)) as never,
)
+32 -8
View File
@@ -27,7 +27,7 @@ export interface EntryPoint {
export interface Interface {
readonly add: (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
@@ -124,14 +124,16 @@ const layer = Layer.effect(
}
return pkg
})
const reify = (input: { dir: string; add?: string[] }) =>
const refreshed = new Set<string>()
const reify = (input: { dir: string; add?: string[]; update?: boolean }) =>
Effect.gen(function* () {
yield* flock.acquire(`npm-install:${input.dir}`)
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const add = input.add ?? []
const npmOptions = yield* NpmConfig.load(input.dir)
const options = input.update ? { ...npmOptions, preferOnline: true, noGitRevCache: true } : npmOptions
const arborist = new Arborist({
...npmOptions,
...options,
path: input.dir,
binLinks: true,
progress: false,
@@ -141,8 +143,9 @@ const layer = Layer.effect(
return yield* Effect.tryPromise({
try: () =>
arborist.reify({
...npmOptions,
...options,
add,
update: input.update,
save: true,
saveType: "prod",
}),
@@ -159,19 +162,33 @@ const layer = Layer.effect(
}),
)
const add = Effect.fn("Npm.add")(function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) {
const add = Effect.fn("Npm.add")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const parsedName = (() => {
const parsed = (() => {
try {
return npa(pkg).name ?? undefined
return npa(pkg)
} catch {
return undefined
}
})()
const parsedName = parsed?.name ?? undefined
const dir = yield* directory(pkg)
const name = yield* installedName(pkg, dir, parsedName)
const cached = yield* afs.existsSafe(path.join(dir, "node_modules", name))
const refresh = options?.refresh && isMutable(parsed) && !refreshed.has(pkg)
if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) {
if (refresh) {
refreshed.add(pkg)
if (cached)
yield* reify({ dir, add: [pkg], update: true }).pipe(
Effect.catchCause(() => Effect.logWarning("failed to refresh cached package; using installed version")),
)
}
if (cached) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
}
@@ -283,3 +300,10 @@ export async function resolve(...args: Parameters<Interface["resolve"]>) {
export async function which(...args: Parameters<Interface["which"]>) {
return runPromise((svc) => svc.which(...args))
}
function isMutable(parsed: { readonly type: string; readonly gitCommittish?: string | null } | undefined) {
if (!parsed) return false
if (["tag", "range"].includes(parsed.type)) return true
if (parsed.type !== "git") return false
return !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(parsed.gitCommittish ?? "")
}
+3
View File
@@ -16200,6 +16200,9 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
+3
View File
@@ -16200,6 +16200,9 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
+3 -2
View File
@@ -93,8 +93,9 @@ opencode2 plugin add 'github:acme/plugins#main::path:packages/opencode-plugin'
Branches, tags, complete commit hashes, and npm's `::path:` repository-subdirectory selectors are supported. Configure
local paths directly; tarball and npm alias targets are not accepted by `plugin add`.
Changes under watched config directories reload automatically. Restart OpenCode after changing an installed package
version or an unwatched dependency.
Changes under watched config directories reload automatically. On server startup, OpenCode refreshes unpinned package and
Git plugins once, then uses that result for the lifetime of the server. Exact npm versions and full Git commit hashes stay
pinned. Changes to unwatched local dependencies may still require restarting OpenCode.
```sh
touch .opencode/plugins/concise.ts