Compare commits

...
23 changed files with 230 additions and 69 deletions
@@ -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"
+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
@@ -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 ?? []),
]
+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) => ({
@@ -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
@@ -14960,6 +14960,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 })) }
: {}),
}
}
@@ -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",
+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,
)
+3
View File
@@ -14960,6 +14960,9 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}
+3
View File
@@ -14960,6 +14960,9 @@
"name": {
"type": "string"
},
"text": {
"type": "string"
},
"mention": {
"$ref": "#/components/schemas/Prompt.Mention"
}