mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-25 19:16:15 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a065040f06 | ||
|
|
bcc79568f0 |
@@ -459,7 +459,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 }
|
||||
|
||||
@@ -2654,6 +2654,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"
|
||||
@@ -2929,6 +2930,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"
|
||||
@@ -3204,6 +3206,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"
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -138,7 +138,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 ?? []),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}),
|
||||
|
||||
@@ -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>"]),
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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: "" },
|
||||
])
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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" }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -14960,6 +14960,9 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"mention": {
|
||||
"$ref": "#/components/schemas/Prompt.Mention"
|
||||
}
|
||||
|
||||
@@ -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)(
|
||||
|
||||
@@ -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 })) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +390,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",
|
||||
@@ -666,7 +675,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
|
||||
@@ -955,18 +964,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") {
|
||||
@@ -978,6 +985,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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -14960,6 +14960,9 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"mention": {
|
||||
"$ref": "#/components/schemas/Prompt.Mention"
|
||||
}
|
||||
|
||||
@@ -14960,6 +14960,9 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"mention": {
|
||||
"$ref": "#/components/schemas/Prompt.Mention"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user