diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 399b6932a19..489acf5d95a 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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" diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index ea6f4d36208..d16d9875fb2 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -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() + 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 }) }) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 420d6992d33..d352cb68cee 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -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}.` diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 11cb9eed231..51b0f1ef1ef 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -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 ?? []), ] diff --git a/packages/core/src/session/transfer.ts b/packages/core/src/session/transfer.ts index 736d4ae968f..279e8e46d17 100644 --- a/packages/core/src/session/transfer.ts +++ b/packages/core/src/session/transfer.ts @@ -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, diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 54c394a10f4..558f5056026 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -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) => { ].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> } @@ -64,6 +80,7 @@ export type Draft = { } export interface Interface extends State.Transformable { + readonly get: (id: ID) => Effect.Effect readonly list: () => Effect.Effect } @@ -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()) }), diff --git a/packages/core/src/skill/instructions.ts b/packages/core/src/skill/instructions.ts index fbfafbee208..1d23cdc693a 100644 --- a/packages/core/src/skill/instructions.ts +++ b/packages/core/src/skill/instructions.ts @@ -26,7 +26,6 @@ const render = (skills: ReadonlyArray) => [ "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."] : ["", ...entries(skills), ""]), diff --git a/packages/core/src/tool/plugin/skill.ts b/packages/core/src/tool/plugin/skill.ts index d7af8b50b66..ffd6e780d96 100644 --- a/packages/core/src/tool/plugin/skill.ts +++ b/packages/core/src/tool/plugin/skill.ts @@ -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) => ({ diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index dc47def484e..1821d3bf28f 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -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: "" }, ]) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 576bdd23092..6ddbe2f857e 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -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" } }, ]) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index dde3d8876ae..706ecc30227 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -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: "Use Effect", + }) + const api = SkillAttachment.make({ + id: Skill.ID.make("api-design"), + name: Skill.Name.make("API design"), + text: "Design APIs", + }) + 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: "Use Effect" }, + { type: "text", text: "Design APIs" }, + { type: "text", text: "Use @effect and @api-design" }, + ], + metadata: {}, + }), + ]) + }) + test("does not inject skill content for reference-only attachments", () => { const messages = toLLMMessages( [ diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts index 61ff5179d14..7f659287e12 100644 --- a/packages/core/test/session-skill.test.ts +++ b/packages/core/test/session-skill.test.ts @@ -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" }), + ]) }), ) diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index c4f5cbc1832..fc2f65aeb4b 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -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() }), ) diff --git a/packages/core/test/skill/instructions.test.ts b/packages/core/test/skill/instructions.test.ts index c1046822ee8..dcb54d478ed 100644 --- a/packages/core/test/skill/instructions.test.ts +++ b/packages/core/test/skill/instructions.test.ts @@ -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.", "", " ", " effect", diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index c3c65a14153..ded7c652744 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -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), }), ) diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index d9abcd5e4b8..238ca367b22 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -16200,6 +16200,9 @@ "name": { "type": "string" }, + "text": { + "type": "string" + }, "mention": { "$ref": "#/components/schemas/Prompt.Mention" } diff --git a/packages/schema/src/prompt.ts b/packages/schema/src/prompt.ts index a0a434141db..d458f8283ff 100644 --- a/packages/schema/src/prompt.ts +++ b/packages/schema/src/prompt.ts @@ -57,6 +57,7 @@ export interface SkillAttachment extends Schema.Schema.Type { ).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)( diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index 5de3b8936c6..a911336f5da 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -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 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 } export type QueuedPromptAction = "steer" | "cancel" diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 54a5334060d..3e2aca19002 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -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, ) diff --git a/packages/www/openapi.json b/packages/www/openapi.json index d9abcd5e4b8..238ca367b22 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -16200,6 +16200,9 @@ "name": { "type": "string" }, + "text": { + "type": "string" + }, "mention": { "$ref": "#/components/schemas/Prompt.Mention" } diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index d9abcd5e4b8..238ca367b22 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -16200,6 +16200,9 @@ "name": { "type": "string" }, + "text": { + "type": "string" + }, "mention": { "$ref": "#/components/schemas/Prompt.Mention" }