refactor(protocol): simplify skill and reference contracts

This commit is contained in:
Dax Raad
2026-09-13 18:52:55 -04:00
parent 8905af5074
commit 199aabe9e2
45 changed files with 91 additions and 1226 deletions
+9 -9
View File
@@ -88,15 +88,15 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 008 | `GET` | `/api/agent` | `agent.list` | Keep | Request and response accepted as-is. |
| [x] 009 | `GET` | `/api/agent/{agentID}` | `agent.get` | Keep | Request, response, and not-found error accepted as-is. |
| [x] 010 | `GET` | `/api/plugin` | `plugin.list` | Keep | Request and response accepted as-is. |
| [ ] 012 | `POST` | `/api/plugin/check` | `plugin.check` | | |
| [ ] 013 | `POST` | `/api/plugin/update` | `plugin.update` | | |
| [ ] 014 | `GET` | `/api/model` | `model.list` | | |
| [ ] 015 | `GET` | `/api/model/default` | `model.default` | | |
| [ ] 016 | `GET` | `/api/provider` | `provider.list` | | |
| [ ] 017 | `GET` | `/api/provider/{providerID}` | `provider.get` | | |
| [ ] 018 | `GET` | `/api/command` | `command.list` | | |
| [ ] 019 | `GET` | `/api/skill` | `skill.list` | | |
| [ ] 020 | `GET` | `/api/reference` | `reference.list` | | |
| [x] 012 | `POST` | `/api/plugin/check` | `plugin.check` | Keep | Request and response accepted as-is. |
| [x] 013 | `POST` | `/api/plugin/update` | `plugin.update` | Keep | Request and errors accepted as-is. |
| [x] 014 | `GET` | `/api/model` | `model.list` | Keep | Request and response accepted as-is. |
| [x] 015 | `GET` | `/api/model/default` | `model.default` | Keep | Request and nullable response accepted as-is. |
| [x] 016 | `GET` | `/api/provider` | `provider.list` | Keep | Request and response accepted as-is. |
| [x] 017 | `GET` | `/api/provider/{providerID}` | `provider.get` | Keep | Request, response, and not-found error accepted as-is. |
| [x] 018 | `GET` | `/api/command` | `command.list` | Keep | Request and response accepted as-is. |
| [x] 019 | `GET` | `/api/skill` | `skill.list` | Keep | Renamed `location` to `path`; removed the skill-specific `slash` flag and slash-command behavior. |
| [x] 020 | `GET` | `/api/reference` | `reference.list` | Keep | Removed duplicate `description` and `hidden` fields from nested `source`. |
| [ ] 021 | `GET` | `/api/config` | `config.get` | | |
| [ ] 022 | `GET` | `/api/config/preferences` | `config.preferences` | | |
| [ ] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | | |
@@ -1,195 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/slash-skills"
const projectID = "proj_slash_skills"
const sessionID = "ses_slash_skills"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
async function setup(page: Page, queued = false) {
const prompts: Record<string, unknown>[] = []
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "slash-skills",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
projectID,
directory,
title: "Slash skills",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
sessionStatus: queued ? { [sessionID]: { type: "running" } } : {},
inbox: queued
? [
{
id: "inb_slash_skill",
sessionID,
timeCreated: 1700000000000,
type: "user",
payload: { text: "Explain caching" },
delivery: "queue",
},
]
: [],
findFiles: ({ query }) =>
query.includes("cache")
? [
{
name: "cache.ts",
path: "src/cache.ts",
absolute: `${directory}/src/cache.ts`,
type: "file",
ignored: false,
},
]
: [],
onPrompt: ({ body }) => prompts.push(body),
})
await page.route("**/api/skill?*", (route) =>
route.fulfill({
json: {
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: [
{ id: "show-me", slash: true, autoinvoke: false },
{ id: "hidden", slash: false },
{ id: "implicit" },
{ id: "review", slash: true },
{ id: "model", slash: true },
].map((skill) => ({
name: skill.id === "show-me" ? "Show Me" : skill.id,
description: "Explain the current topic visually",
location: `/skills/${skill.id}/SKILL.md`,
content: "Explain visually",
...skill,
})),
},
headers: { "access-control-allow-origin": "*" },
}),
)
await page.route("**/api/command?*", (route) =>
route.fulfill({
json: {
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: [{ name: "review", description: "Review changes" }],
},
headers: { "access-control-allow-origin": "*" },
}),
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
const editor = composer.locator('[data-component="composer-editor"]')
await expect(editor).toBeEditable()
return { prompts, composer, editor }
}
for (const selection of ["keyboard", "pointer"]) {
test(`selects and submits a manual-only slash skill with ${selection}`, async ({ page }) => {
const { prompts, editor } = await setup(page)
await editor.fill("/show")
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toContainText("/show-me")
await page.screenshot({ path: test.info().outputPath("slash-menu.png") })
if (selection === "keyboard") await editor.press("Enter")
if (selection === "pointer") await skill.click()
await expect(editor).toHaveText("/show-me ")
await expect(editor).toBeFocused()
await editor.pressSequentially("explain caching")
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
text: "/show-me explain caching",
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
})
await expect(editor).toBeEmpty()
})
}
test("respects slash flags and command precedence without hiding context skills", async ({ page }) => {
const { editor } = await setup(page)
await editor.fill("/")
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toBeVisible()
await expect(page.locator('[data-suggestion-id="custom.review"]')).toBeVisible()
await expect(page.locator('[data-suggestion-id="model.choose"]')).toBeVisible()
for (const id of ["hidden", "implicit", "review", "model"]) {
await expect(page.locator(`[data-suggestion-id="skill:${id}"]`)).toHaveCount(0)
}
await editor.press("Escape")
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toHaveCount(0)
await expect(editor).toBeFocused()
await editor.fill("@hidden")
const hidden = page.locator('[data-suggestion-id="skill:hidden"]')
await expect(hidden).toContainText("@hidden")
await hidden.click()
await expect(editor).toHaveText("@hidden ")
await expect(editor).toBeFocused()
})
test("preserves structured attachments when adding a slash skill from the command menu", async ({ page }) => {
const { prompts, composer, editor } = await setup(page)
await editor.fill("explain @cache")
const file = page.locator('[data-suggestion-id="file:src/cache.ts"]')
await expect(file).toBeVisible()
await file.click()
await expect(editor).toHaveText("explain @src/cache.ts ")
await composer.getByRole("button", { name: "Add images and files" }).click()
await page.getByRole("menuitem", { name: "Commands" }).click()
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toBeVisible()
await skill.click()
await expect(editor).toHaveText("/show-me explain @src/cache.ts ")
await expect(editor).toBeFocused()
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
files: [{ name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } }],
})
})
for (const select of [true, false]) {
test(`keeps slash skills in queued edits (${select ? "selected" : "typed"})`, async ({ page }) => {
const { prompts, editor } = await setup(page, true)
const row = page.locator('[data-component="session-queue-row"]')
await row.getByText("Explain caching", { exact: true }).click()
await expect(editor).toHaveText("Explain caching")
if (select) {
await editor.fill("/show")
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
await expect(skill).toBeVisible()
await skill.click()
await expect(editor).toHaveText("/show-me ")
await editor.pressSequentially("explain caching")
}
if (!select) await editor.fill("/show-me explain caching")
await editor.press("Enter")
await expect.poll(() => prompts.length).toBe(1)
expect(prompts[0]).toMatchObject({
delivery: "queue",
text: "/show-me explain caching",
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
})
})
}
@@ -404,7 +404,7 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
{
id: "summary-skill",
name: "summary-skill",
location: "/skills/summary/SKILL.md",
path: "/skills/summary/SKILL.md",
content: "Summary skill",
},
],
@@ -42,7 +42,7 @@ for (const service of services) {
state: { status: "active" },
},
]
: [{ id: service.item, name: service.item, location: "/skills/summary/SKILL.md", content: "Summary" }]
: [{ id: service.item, name: service.item, path: "/skills/summary/SKILL.md", content: "Summary" }]
return route.fulfill({ json: { location: { directory: fixture.directory }, data: empty ? [] : items } })
},
)
@@ -235,11 +235,11 @@ test("catalog submenus show project plugins and skills, refresh on reopen, and d
json: {
location: { directory: fixture.directory },
data: [
{ id: "find-skills", name: "find-skills", location: "/skills/find/SKILL.md", content: "Find skills" },
{ id: "find-skills", name: "find-skills", path: "/skills/find/SKILL.md", content: "Find skills" },
{
id: "review-animations",
name: "review-animations",
location: "/skills/review/SKILL.md",
path: "/skills/review/SKILL.md",
content: "Review animations",
},
],
@@ -99,7 +99,7 @@ describe("Composer store", () => {
expect(prompt.state.prompt).toEqual([{ type: "text", content: "old", start: 0, end: 3 }])
})
test("prepends a slash skill to an attachment-only draft without flattening it", () => {
test("prepends a skill mention to an attachment-only draft without flattening it", () => {
const prompt = createPromptStore()
prompt.setPrompt([{ type: "file", path: "one", content: "@one", start: 0, end: 4 }], 4)
prompt.addMention(
@@ -107,14 +107,14 @@ describe("Composer store", () => {
type: "skill",
id: Skill.ID.make("show-me"),
name: Skill.Name.make("Show Me"),
content: "/show-me",
content: "@show-me",
start: 0,
end: 0,
},
{ start: 0, end: 0 },
)
expect(prompt.state.prompt).toMatchObject([
{ type: "skill", id: "show-me", content: "/show-me", start: 0, end: 8 },
{ type: "skill", id: "show-me", content: "@show-me", start: 0, end: 8 },
{ type: "text", content: " ", start: 8, end: 9 },
{ type: "file", path: "one", content: "@one", start: 9, end: 13 },
])
+1 -22
View File
@@ -21,7 +21,7 @@ import type { ImageAttachmentPart } from "./state"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit, withSlashSkill } from "./submit"
import { createComposerSubmit } from "./submit"
export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
@@ -243,9 +243,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
type: "builtin" as const,
})),
])
const slashSkills = createMemo(() =>
skills().filter((skill) => skill.slash === true && !slashCommands().some((item) => item.trigger === skill.id)),
)
const commands = createMemo<ComposerSuggestion[]>(() => [
...slashCommands().map((item) => ({
id: item.id,
@@ -256,29 +253,12 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
description: item.description,
keybind: command.keybindParts(item.id),
})),
...slashSkills().map((skill) => ({
id: `skill:${skill.id}`,
kind: "skill" as const,
label: `/${skill.id}`,
trigger: skill.id,
title: skill.name,
description: skill.description,
mention: {
type: "skill" as const,
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
content: `/${skill.id}`,
start: 0,
end: 0,
},
})),
])
const variants = createMemo(() => ["default", ...adapter.controls().model.selection.variant.list()])
const submission = createComposerSubmit({
adapter,
mode,
commands: () => data.location.command.list({ directory: sdk().directory }),
skills: slashSkills,
editor: () => editor,
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
addToHistory: (value, mode) => controller.addHistory(value, mode),
@@ -407,7 +387,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
if (queue?.editing()) {
prompt.set(withSlashSkill(prompt.current(), slashSkills()))
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
return
}
-116
View File
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageUser } from "@opencode/client/promise"
import { Skill } from "@opencode/schema/skill"
import { AbsolutePath } from "@opencode/schema/schema"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import { createMemoryComposerState } from "./state"
import { createComposerSubmit } from "./submit"
@@ -32,16 +31,6 @@ const selection = {
},
} satisfies ModelSelection
const slashSkill = Skill.Info.make({
id: Skill.ID.make("show-me"),
name: Skill.Name.make("Show Me"),
description: "Explain visually",
slash: true,
autoinvoke: false,
location: AbsolutePath.make("/skills/show-me/SKILL.md"),
content: "Explain visually",
})
function controls(): ComposerControls {
return {
agents: {
@@ -64,13 +53,11 @@ function submitInput(
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
skills: () => readonly Skill.Info[] | undefined = () => [],
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
skills,
editor: () => undefined,
queueScroll() {},
addToHistory() {},
@@ -268,107 +255,6 @@ describe("Composer submission", () => {
expect(state.current()[0]).toMatchObject({ content: "/review changes" })
})
test("submits a slash skill with its trailing text and attachments", async () => {
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
state.set([
{ type: "text", content: "/show-me explain ", start: 0, end: 17 },
{ type: "file", path: "src/cache.ts", content: "@src/cache.ts", start: 17, end: 30 },
])
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => [slashSkill],
).submit(new Event("submit"))
const request = await admitted.promise
expect(request.text).toBe("/show-me explain @src/cache.ts")
expect(request.skills).toEqual([
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
])
expect(request.files).toEqual([
{ uri: "file:///C:/repo/src/cache.ts", name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } },
])
})
test.each([
{ text: "/show-me", slash: true, expected: ["show-me"] },
{ text: "/show-me\nexplain caching", slash: true, expected: ["show-me"] },
{ text: "/show-me\texplain caching", slash: true, expected: ["show-me"] },
{ text: "/show-me", slash: false, expected: [] },
{ text: "/show-me", slash: undefined, expected: [] },
{ text: "/show-me-extra", slash: true, expected: [] },
{ text: "Explain /show-me", slash: true, expected: [] },
])("resolves raw slash skill input $text with slash=$slash", async ({ text, slash, expected }) => {
const state = createMemoryComposerState({ prompt: text }).capture()
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => [{ ...slashSkill, slash }],
).submit(new Event("submit"))
const request = await admitted.promise
expect(request.text).toBe(text)
expect(request.skills?.map((skill) => skill.id)).toEqual([...expected])
})
test("captures slash skills before creating a session in a new worktree", async () => {
const state = createMemoryComposerState({ prompt: "/show-me" }).capture()
let skills: readonly Skill.Info[] | undefined = [slashSkill]
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls: [], prompt: async (value) => admitted.resolve(value) })
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state,
ready: () => true,
controls,
working: () => false,
submitted() {},
async start() {
skills = undefined
return { session: target, cleanupReady: Promise.resolve() }
},
}
await submitInput(
adapter,
undefined,
"normal",
() => [],
() => skills,
).submit(new Event("submit"))
expect((await admitted.promise).skills).toEqual([
expect.objectContaining({ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }),
])
})
test("sends one captured value with explicit delivery after selection switches", async () => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
const calls: string[] = []
@@ -652,7 +538,6 @@ describe("Composer submission", () => {
notify,
"normal",
() => [],
() => [slashSkill],
)
await submission.submit(new Event("submit"))
@@ -744,7 +629,6 @@ describe("Composer submission", () => {
undefined,
"normal",
() => catalog,
() => [{ ...slashSkill, id: Skill.ID.make("review") }],
).submit(new Event("submit"))
expect(await sent.promise).toBe("command")
+1 -24
View File
@@ -1,6 +1,5 @@
import { SessionMessage } from "@opencode/schema/session-message"
import type { SessionMessageUser, SkillInfo } from "@opencode/client/promise"
import { Skill } from "@opencode/schema/skill"
import type { SessionMessageUser } from "@opencode/client/promise"
import { Event } from "@opencode/schema/event"
import type { Accessor } from "solid-js"
import type { PromptHistoryComment } from "./history/entry"
@@ -30,7 +29,6 @@ type ComposerSubmitInput = {
adapter: ComposerAdapter
mode: Accessor<"normal" | "shell">
commands: Accessor<readonly { name: string }[] | undefined>
skills: Accessor<readonly Pick<SkillInfo, "id" | "name" | "slash">[] | undefined>
editor: () => HTMLDivElement | undefined
queueScroll: () => void
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
@@ -71,7 +69,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
const command = value.mode === "normal" ? findCommand(input.commands(), value.text) : undefined
if (value.mode === "normal" && !command) value.prompt = withSlashSkill(value.prompt, input.skills())
try {
const started =
@@ -292,26 +289,6 @@ function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text
return { command, arguments: arguments_.join(" ") }
}
export function withSlashSkill(prompt: Prompt, skills: ReturnType<ComposerSubmitInput["skills"]>): Prompt {
const first = prompt[0]
if (first?.type !== "text") return prompt
const name = /^\/(\S+)(?:\s|$)/.exec(first.content)?.[1]
const skill = skills?.find((item) => item.slash === true && item.id === name)
if (!skill || prompt.some((part) => part.type === "skill" && part.id === skill.id)) return prompt
const content = `/${skill.id}`
return [
{
type: "skill",
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
content,
start: 0,
end: content.length,
},
{ ...first, content: first.content.slice(content.length), start: content.length },
...prompt.slice(1),
]
}
async function sendCommand(
session: ComposerSession,
@@ -27,10 +27,10 @@ import "./project.css"
type SkillItem = {
name: string
location: string
path: string
}
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
const skillKey = (item: SkillItem) => `${item.name}\n${item.path}`
const ExtensionCard: Component<{ children: JSX.Element }> = (props) => (
<SettingsList variant="catalog">{props.children}</SettingsList>
+6 -17
View File
@@ -6,7 +6,6 @@ import {
type OpenCodeClient,
type SessionInfo,
type SessionMessageInfo,
type SkillInfo,
} from "@opencode/client/promise"
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
import type {
@@ -71,7 +70,6 @@ type Catalog = {
readonly modes: Array<{ id: string; name: string; description?: string }>
readonly defaultModeID: string
readonly commands: CommandInfo[]
readonly skills: SkillInfo[]
}
type Attached = {
@@ -90,7 +88,6 @@ type PreparedPrompt = {
readonly synthetic: ReadonlyArray<string>
readonly slash?: { readonly name: string; readonly args: string }
readonly command?: CommandInfo
readonly skill?: SkillInfo
}
export interface Interface {
@@ -157,11 +154,8 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
update: {
sessionUpdate: "available_commands_update",
availableCommands: [
...state.catalog.commands,
...state.catalog.skills.filter(
(skill) => !state.catalog.commands.some((command) => command.name === skill.name),
),
].map((command) => ({ name: command.name, description: command.description ?? "" })),
...state.catalog.commands.map((command) => ({ name: command.name, description: command.description ?? "" })),
],
},
})
return state
@@ -365,9 +359,8 @@ function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messag
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
const slash = detectSlashCommand(text)
const command = slash ? catalog.commands.find((item) => item.name === slash.name) : undefined
const skill = slash ? catalog.skills.find((item) => item.name === slash.name) : undefined
const start = turnStart(messageID, slash, skill)
return { start, text, files, synthetic, slash, command, skill }
const start = turnStart(messageID, slash)
return { start, text, files, synthetic, slash, command }
}
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
@@ -381,7 +374,6 @@ async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: P
})
}
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
if (prompt.skill) return client.session.skill({ sessionID: session.id, id: prompt.start.id, skill: prompt.skill.id })
if (prompt.command) {
return client.session.command(
{
@@ -400,9 +392,8 @@ async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: P
)
}
function turnStart(messageID: string, slash: PreparedPrompt["slash"], skill: SkillInfo | undefined): TurnStart {
function turnStart(messageID: string, slash: PreparedPrompt["slash"]): TurnStart {
if (slash?.name === "compact") return { type: "compaction", id: messageID }
if (skill) return { type: "skill", id: messageID }
return { type: "input", id: messageID }
}
@@ -412,12 +403,11 @@ async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog
const deadline = Date.now() + 5_000
let missing = "No models are available"
while (Date.now() < deadline) {
const [modelResult, defaultResult, agentResult, commandResult, skillResult] = await Promise.all([
const [modelResult, defaultResult, agentResult, commandResult] = await Promise.all([
client.model.list({ location }),
client.model.default({ location }),
client.agent.list({ location }),
client.command.list({ location }),
client.skill.list({ location }),
])
const models = modelResult.data.filter((model) => model.enabled)
const preferred = defaultResult.data
@@ -439,7 +429,6 @@ async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
defaultModeID: defaultAgent.id,
commands: commandResult.data,
skills: skillResult.data.filter((skill) => skill.slash !== false),
}
}
missing = defaultModel ? "No primary agents are available" : "No models are available"
@@ -32,7 +32,6 @@ describe("acp service directory behavior", () => {
"/api/model/default",
"/api/agent",
"/api/command",
"/api/skill",
].map((path) =>
fixture.requests
.filter((request) => request.path === path)
@@ -43,7 +42,6 @@ describe("acp service directory behavior", () => {
["/workspace", "/other"],
["/workspace", "/other"],
["/workspace", "/other"],
["/workspace", "/other"],
])
expect(
fixture.requests
@@ -73,9 +71,9 @@ describe("acp service directory behavior", () => {
: [],
),
).toEqual([
["review", "verify"],
["review", "verify"],
["review", "verify"],
["review"],
["review"],
["review"],
])
})
-14
View File
@@ -6,7 +6,6 @@ import {
type ModelInfo,
type ModelRef,
type SessionInfo,
type SkillInfo,
type TokenUsageInfo,
} from "@opencode/client/promise"
import { ACPService } from "../../src/acp/service"
@@ -34,7 +33,6 @@ type FixtureOptions = {
readonly defaultModel?: ModelInfo
readonly agents?: readonly AgentInfo[]
readonly commands?: readonly CommandInfo[]
readonly skills?: readonly SkillInfo[]
}
export const testModel = {
@@ -89,15 +87,6 @@ export const reviewCommand = {
description: "Review changes",
} satisfies CommandInfo
export const verifySkill = {
id: "verify",
name: "verify",
description: "Verify work",
slash: true,
location: "/skills/verify.md",
content: "verify",
} satisfies SkillInfo
export function makeSession(
id: string,
input: {
@@ -178,9 +167,6 @@ export function makeACPFixture(options: FixtureOptions = {}) {
if (request.path === "/api/command") {
return Response.json({ location, data: options.commands ?? [reviewCommand] })
}
if (request.path === "/api/skill") {
return Response.json({ location, data: options.skills ?? [verifySkill] })
}
return new Response(null, { status: 404 })
},
})
+2 -21
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { makeACPFixture, makeSession, secondModel, type FixtureContext, type FixtureRequest } from "./service-fixture"
describe("acp service prompt routing and usage", () => {
test("routes slash commands, skills, and compact through their session endpoints", async () => {
test("routes slash commands and compact through their session endpoints", async () => {
await using fixture = makeACPFixture({
fetch(request, context) {
if (request.method === "POST" && request.path === "/api/session") {
@@ -14,15 +14,6 @@ describe("acp service prompt routing and usage", () => {
if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/skill") {
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: id.replace(/^msg_/, "evt_"),
type: "session.skill.activated",
data: { sessionID: "ses_routes", skill: "verify" },
})
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/compact") {
const id = requestID(request)
completeTurn(context, "ses_routes", {
@@ -41,22 +32,13 @@ describe("acp service prompt routing and usage", () => {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "/review now" }],
})
const skillResult = await fixture.service.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "/verify" }],
})
const compactResult = await fixture.service.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "/compact" }],
})
expect([commandResult.stopReason, skillResult.stopReason, compactResult.stopReason]).toEqual([
"end_turn",
"end_turn",
"end_turn",
])
expect([commandResult.stopReason, compactResult.stopReason]).toEqual(["end_turn", "end_turn"])
const command = fixture.requests.find((request) => request.path === "/api/session/ses_routes/command")
const skill = fixture.requests.find((request) => request.path === "/api/session/ses_routes/skill")
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
expect(command?.body).toMatchObject({
command: "review",
@@ -64,7 +46,6 @@ describe("acp service prompt routing and usage", () => {
files: [],
delivery: "steer",
})
expect(skill?.body).toMatchObject({ id: expect.any(String), skill: "verify" })
expect(compact?.body).toMatchObject({ id: expect.any(String) })
expect(fixture.requests.some((request) => request.path === "/api/session/ses_routes/prompt")).toBe(false)
})
+1 -11
View File
@@ -23,7 +23,6 @@ describe("acp service", () => {
if (url.pathname === "/api/agent") return Response.json({ location, data: [agent] })
if (url.pathname === "/api/command")
return Response.json({ location, data: [{ name: "review", template: "" }] })
if (url.pathname === "/api/skill") return Response.json({ location, data: [skill] })
if (url.pathname === "/api/session" && request.method === "POST") return Response.json({ data: session })
if (url.pathname === "/api/mcp/docs" && request.method === "PUT") return new Response(null, { status: 204 })
return new Response(null, { status: 404 })
@@ -63,7 +62,7 @@ describe("acp service", () => {
sessionId: "ses_acp",
update: {
sessionUpdate: "available_commands_update",
availableCommands: [{ name: "review" }, { name: "verify", description: "Verify work" }],
availableCommands: [{ name: "review", description: "" }],
},
})
} finally {
@@ -95,15 +94,6 @@ const agent = {
permissions: [],
}
const skill = {
id: "verify",
name: "verify",
description: "Verify work",
slash: true,
location: "/skills/verify.md",
content: "verify",
}
const session = {
id: "ses_acp",
projectID: "global",
@@ -1,24 +0,0 @@
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, initialize, newSession, verifierSkill } from "./subprocess"
describe("acp skills subprocess", () => {
test("skill slash command appears through available_commands_update", async () => {
await using fixture = await createAcpFixture({ skill: verifierSkill })
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const update = await acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === session.sessionId &&
params.update.sessionUpdate === "available_commands_update" &&
params.update.availableCommands.some(
(command) => command.name === "verifier-skill" && command.description.length > 0,
),
)
expect(update.params.sessionId).toBe(session.sessionId)
}, 60_000)
})
+3 -10
View File
@@ -322,9 +322,8 @@ export type SkillInfo = {
id: string
name: string
description?: string
slash?: boolean
autoinvoke?: boolean
location: string
path: string
content: string
}
@@ -398,15 +397,9 @@ export type ShellInfo1 = {
time: { started: number; completed?: number }
}
export type ReferenceLocalSource = { type: "local"; path: string; description?: string; hidden?: boolean }
export type ReferenceLocalSource = { type: "local"; path: string }
export type ReferenceGitSource = {
type: "git"
repository: string
branch?: string
description?: string
hidden?: boolean
}
export type ReferenceGitSource = { type: "git"; repository: string; branch?: string }
export type WorktreeDirectory = { directory: string; strategy?: string }
@@ -9,7 +9,6 @@ import { Skill } from "../../skill.js"
const Frontmatter = Schema.Struct({
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
slash: Schema.Boolean.pipe(Schema.optional),
metadata: Schema.Unknown.pipe(Schema.optional),
})
const decodeFrontmatter = SchemaParser.decodeUnknownResult(Frontmatter)
@@ -42,7 +41,6 @@ export function parse(directory: string, filepath: string, content: string): Par
path.dirname(filepath) === directory && path.basename(filepath) !== "SKILL.md"
? path.basename(filepath, ".md")
: path.basename(path.dirname(filepath))
const slash = metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash
const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
return {
_tag: "Parsed",
@@ -50,9 +48,8 @@ export function parse(directory: string, filepath: string, content: string): Par
id: Skill.ID.make(id),
name: Skill.Name.make(frontmatter.name ?? id),
...(frontmatter.description === undefined ? {} : { description: frontmatter.description }),
...(slash === undefined ? {} : { slash }),
...(autoinvoke === undefined ? {} : { autoinvoke }),
location: AbsolutePath.make(filepath),
path: AbsolutePath.make(filepath),
content: markdown.content,
},
}
+2 -3
View File
@@ -30,7 +30,7 @@ export const Plugin = define({
id: Skill.ID.make("opencode"),
name: Skill.Name.make("OpenCode"),
description: OpencodeDescription,
location: AbsolutePath.make("/builtin/opencode.md"),
path: AbsolutePath.make("/builtin/opencode.md"),
content: OpencodeContent,
}),
)
@@ -39,8 +39,7 @@ export const Plugin = define({
id: Skill.ID.make("report"),
name: Skill.Name.make("Report"),
description: REPORT_DESCRIPTION,
slash: true,
location: AbsolutePath.make("/builtin/report.md"),
path: AbsolutePath.make("/builtin/report.md"),
content: reportContent,
}),
)
+3 -3
View File
@@ -35,7 +35,7 @@ export const available = (skills: ReadonlyArray<Info>, agent: Agent.Info) =>
skills.filter((skill) => Permission.evaluate("skill", skill.id, agent.permissions).effect !== "deny")
export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
const directory = path.dirname(skill.location)
const directory = path.dirname(skill.path)
return [
`<skill_content name="${skill.name}">`,
`# Skill: ${skill.name}`,
@@ -54,9 +54,9 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
}
export const prepare = Effect.fn("Skill.prepare")(function* (fs: FSUtil.Interface, skill: Info) {
const directory = path.dirname(skill.location)
const directory = path.dirname(skill.path)
const files =
path.basename(skill.location) === "SKILL.md"
path.basename(skill.path) === "SKILL.md"
? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
.filter((file) => path.basename(file) !== "SKILL.md")
.toSorted()
+1 -1
View File
@@ -80,7 +80,7 @@ describe("config plugin reloads", () => {
Bun.write(skill, "---\nname: probe\ndescription: Hot reload\n---\nTest skill"),
)
yield* waitUntil(skills.list().pipe(Effect.map((items) => items.some((item) => item.id === "probe"))))
expect((yield* skills.list())[0]?.location).toBe(AbsolutePath.make(skill))
expect((yield* skills.list())[0]?.path).toBe(AbsolutePath.make(skill))
yield* Effect.promise(() => fs.rm(root, { recursive: true }))
yield* waitUntil(skills.list().pipe(Effect.map((items) => items.length === 0)))
yield* Effect.promise(() => Bun.write(skill, "---\nname: probe\ndescription: Recreated\n---\nTest skill"))
+3 -10
View File
@@ -124,7 +124,6 @@ describe("SkillFile.parse", () => {
name: Manual
description: Manual only
metadata:
opencode/slash: "true"
opencode/autoinvoke: false
---
# manual`,
@@ -135,13 +134,12 @@ metadata:
id: Skill.ID.make("manual"),
name: Skill.Name.make("Manual"),
description: "Manual only",
slash: true,
autoinvoke: false,
location: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
path: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
content: "# manual",
},
})
expect(SkillFile.parse(directory, "/repo/skills/foo.md", "---\nslash: true\n---\n# foo")).toMatchObject({
expect(SkillFile.parse(directory, "/repo/skills/foo.md", "# foo")).toMatchObject({
_tag: "Parsed",
skill: { id: Skill.ID.make("foo") },
})
@@ -152,11 +150,6 @@ metadata:
expect(
SkillFile.parse(directory, "/repo/skills/broken.md", "---\ndescription: foo: bar\nmetadata: [\n---\n# broken"),
).toEqual({ _tag: "Skipped", reason: "markdown" })
expect(SkillFile.parse(directory, "/repo/skills/broken.md", "---\nslash: nope\n---\n# broken")).toMatchObject({
_tag: "Skipped",
reason: "frontmatter",
issue: expect.anything(),
})
})
})
@@ -257,7 +250,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
const review = (yield* skill.list()).find((item) => item.id === "review")
expect(review?.description).toBe("Worktree")
expect(review?.location).toBe(AbsolutePath.make(path.join(worktreeSkills, "review", "SKILL.md")))
expect(review?.path).toBe(AbsolutePath.make(path.join(worktreeSkills, "review", "SKILL.md")))
}),
),
),
-1
View File
@@ -49,7 +49,6 @@ describe("SkillPlugin.Plugin", () => {
description: expect.stringContaining("opencode issue"),
}),
)
expect(report?.slash).toBe(true)
expect(report?.content).toContain("- opencode version: 1.2.3")
expect(report?.content).toContain("- install/channel: beta")
}),
+1 -1
View File
@@ -63,7 +63,7 @@ const skillInfo = Skill.Info.make({
id: Skill.ID.make("guide"),
name: Skill.Name.make("Guide"),
description: "Session guidance",
location: AbsolutePath.make("/skills/guide/SKILL.md"),
path: AbsolutePath.make("/skills/guide/SKILL.md"),
content: " Raw guidance\n",
})
+1 -1
View File
@@ -31,7 +31,7 @@ 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")),
path: AbsolutePath.make(path.resolve("/skills/effect.md")),
content: " Use Effect\n",
})
const locations = makeGlobalNode({
+1 -1
View File
@@ -15,7 +15,7 @@ const info = (id: string, description: string) =>
id: Skill.ID.make(id),
name: Skill.Name.make(id),
description,
location: AbsolutePath.make(`/skills/${id}/SKILL.md`),
path: AbsolutePath.make(`/skills/${id}/SKILL.md`),
content: `# ${id}`,
})
@@ -14,20 +14,20 @@ const effect = Skill.Info.make({
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
description: "Build applications with Effect",
location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
path: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")),
content: "Effect guidance",
})
const hidden = Skill.Info.make({
id: Skill.ID.make("hidden"),
name: Skill.Name.make("Hidden"),
location: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")),
path: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")),
content: "Undescribed guidance",
})
const denied = Skill.Info.make({
id: Skill.ID.make("denied"),
name: Skill.Name.make("Denied"),
description: "Must not be advertised",
location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")),
path: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")),
content: "Denied guidance",
})
const manual = Skill.Info.make({
@@ -35,7 +35,7 @@ const manual = Skill.Info.make({
name: Skill.Name.make("Manual"),
description: "Load only when explicitly selected",
autoinvoke: false,
location: AbsolutePath.make(path.resolve("/skills/manual/SKILL.md")),
path: AbsolutePath.make(path.resolve("/skills/manual/SKILL.md")),
content: "Manual guidance",
})
@@ -85,7 +85,7 @@ describe("SkillInstructions", () => {
id: Skill.ID.make("debugging"),
name: Skill.Name.make("Debugging"),
description: "Diagnose hard bugs",
location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")),
path: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")),
content: "Debugging guidance",
})
let skills = [effect]
+3 -3
View File
@@ -47,7 +47,7 @@ describe("SkillTool", () => {
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
description: "Use Effect",
location: AbsolutePath.make(location),
path: AbsolutePath.make(location),
content: "# Effect\n\nGuidance",
}
let current = [info]
@@ -138,12 +138,12 @@ describe("SkillTool", () => {
id: Skill.ID.make("public"),
name: Skill.Name.make("Public"),
description: "Public guidance",
location: AbsolutePath.make(path.join(tmp.path, "public.md")),
path: AbsolutePath.make(path.join(tmp.path, "public.md")),
content: "Public",
})
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(flat.location, "public"),
fs.writeFile(flat.path, "public"),
fs.writeFile(path.join(tmp.path, "secret.md"), "secret"),
]),
)
+2 -17
View File
@@ -16864,12 +16864,6 @@
},
"branch": {
"type": "string"
},
"description": {
"type": "string"
},
"hidden": {
"type": "boolean"
}
},
"required": ["type", "repository"],
@@ -16906,12 +16900,6 @@
},
"path": {
"type": "string"
},
"description": {
"type": "string"
},
"hidden": {
"type": "boolean"
}
},
"required": ["type", "path"],
@@ -18850,20 +18838,17 @@
"description": {
"type": "string"
},
"slash": {
"type": "boolean"
},
"autoinvoke": {
"type": "boolean"
},
"location": {
"path": {
"type": "string"
},
"content": {
"type": "string"
}
},
"required": ["id", "name", "location", "content"],
"required": ["id", "name", "path", "content"],
"additionalProperties": false
},
"SkillNotFoundErrorEncoded": {
+17 -2
View File
@@ -1,14 +1,29 @@
import { Location } from "@opencode/schema/location"
import { Reference } from "@opencode/schema/reference"
import { Schema } from "effect"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
const PublicLocalSource = Schema.Struct(Struct.omit(Reference.LocalSource.fields, ["description", "hidden"])).annotate({
identifier: "Reference.LocalSource",
})
const PublicGitSource = Schema.Struct(Struct.omit(Reference.GitSource.fields, ["description", "hidden"])).annotate({
identifier: "Reference.GitSource",
})
const PublicSource = Schema.Union([PublicLocalSource, PublicGitSource])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Reference.Source" })
const PublicInfo = Schema.Struct({
...Struct.omit(Reference.Info.fields, ["source"]),
source: PublicSource,
}).annotate({ identifier: "Reference.Info" })
export const ReferenceGroup = HttpApiGroup.make("server.reference")
.add(
HttpApiEndpoint.get("reference.list", "/api/reference", {
query: LocationQuery,
success: Location.response(Schema.Array(Reference.Info)),
success: Location.response(Schema.Array(PublicInfo)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
+1 -2
View File
@@ -28,9 +28,8 @@ export const Info = Schema.Struct({
id: ID,
name: Name,
description: Schema.String.pipe(optional),
slash: Schema.Boolean.pipe(optional),
autoinvoke: Schema.Boolean.pipe(optional),
location: AbsolutePath,
path: AbsolutePath,
content: Schema.String,
}).annotate({ identifier: "Skill.Info" })
@@ -520,17 +520,6 @@ export function Autocomplete(props: {
})
}
for (const skill of data.location.skill
.list(location.current)
?.filter((skill) => skill.slash === true && !commandNames.has(skill.id)) ?? []) {
results.push({
display: "/" + skill.id,
description: skill.description,
kind: "skill",
onSelect: () => insertSlash(skill.id),
})
}
results.sort((a, b) => a.display.localeCompare(b.display))
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
+1 -14
View File
@@ -1144,19 +1144,9 @@ export function Prompt(props: PromptProps) {
}),
)
const slashHead = parseSlashHead(inputText, /\s/)
const isSkill =
!(store.prompt.skills?.length ?? 0) &&
slashHead !== undefined &&
(data.location.skill.list(currentLocation.ref) ?? []).some(
(skill) => skill.slash === true && skill.id === slashHead.name,
)
const isCommand =
slashHead !== undefined &&
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
if (delivery === "queue" && isSkill) {
toast.show({ message: "Skills cannot be queued", variant: "warning" })
return false
}
const editorSelection = editorContext()
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
if (delivery === "queue" && pendingEditorSelection) {
@@ -1170,7 +1160,7 @@ export function Prompt(props: PromptProps) {
void promptModelWarning()
return false
}
const usesModel = !props.sessionID || (store.mode !== "shell" && !isSkill)
const usesModel = !props.sessionID || store.mode !== "shell"
if (usesModel && !local.model.available(selection)) {
toast.show({
title: "Model unavailable",
@@ -1311,9 +1301,6 @@ export function Prompt(props: PromptProps) {
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
restoreEntry()
})
} else if (isSkill) {
move.startSubmit()
dispatch(() => client.api.session.skill({ sessionID: target, skill: slashHead.name }))
} else {
move.startSubmit()
try {
+2 -15
View File
@@ -5,13 +5,11 @@ import type {
ModelListOutput,
OpenCodeClient,
ProviderListOutput,
SkillListOutput,
} from "@opencode/client/promise"
import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
type CurrentAgent = AgentListOutput["data"][number]
type CurrentCommand = CommandListOutput["data"][number]
type CurrentSkill = SkillListOutput["data"][number]
type CurrentProvider = ProviderListOutput["data"][number]
type CurrentModel = ModelListOutput["data"][number]
@@ -46,14 +44,6 @@ function runCommand(input: CurrentCommand): RunCommand {
}
}
function runSkill(input: CurrentSkill): RunCommand {
return {
name: input.id,
description: input.description,
source: "skill",
}
}
export function runProviders(providers: CurrentProvider[], models: CurrentModel[]): RunProvider[] {
const grouped = new Map<string, RunProvider>()
@@ -95,11 +85,8 @@ export async function loadRunCommands(
ref: LocationRef,
signal?: AbortSignal,
): Promise<RunCommand[]> {
const [commands, skills] = await Promise.all([
sdk.command.list(location(ref), ...requestOptions(signal)),
sdk.skill.list(location(ref), ...requestOptions(signal)),
])
return [...commands.data.map(runCommand), ...skills.data.filter((skill) => skill.slash !== false).map(runSkill)]
const commands = await sdk.command.list(location(ref), ...requestOptions(signal))
return commands.data.map(runCommand)
}
export async function loadRunReferences(
-85
View File
@@ -38,7 +38,6 @@ type CommandEntry =
| (PanelEntry & { action: "agent" })
| (PanelEntry & { action: "model" })
| (PanelEntry & { action: "editor" })
| (PanelEntry & { action: "skill" })
| (PanelEntry & { action: "queued" })
| (PanelEntry & { action: "subagent" })
| (PanelEntry & { action: "status" })
@@ -66,10 +65,6 @@ type VariantEntry = PanelEntry & {
current: boolean
}
type SkillEntry = PanelEntry & {
name: string
}
type QueuedPromptEntry = PanelEntry & {
prompt: FooterQueuedPrompt
}
@@ -404,7 +399,6 @@ export function RunCommandMenuBody(props: {
onAgent: () => void
onModel: () => void
onEditor: () => void
onSkill: () => void
onSubagent: () => void
onQueued: () => void
onVariant: () => void
@@ -418,7 +412,6 @@ export function RunCommandMenuBody(props: {
clearShortcut?: string
mono?: boolean
}) {
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
const entries = createMemo<CommandEntry[]>(() => {
const session: CommandEntry[] = [
@@ -467,20 +460,6 @@ export function RunCommandMenuBody(props: {
keywords: "new session clear",
},
]
const prompt: CommandEntry[] =
props.commands() === undefined || skills().length > 0
? [
{
action: "skill" as const,
category: "Prompt",
display: "Skills",
footer: "/skills",
keywords: `skill skills ${skills()
.map((item) => `${item.name} ${item.description ?? ""}`)
.join(" ")}`.trim(),
},
]
: []
const agent: CommandEntry[] = [
{
action: "agent",
@@ -526,7 +505,6 @@ export function RunCommandMenuBody(props: {
]
return [
...session,
...prompt,
...agent,
{
action: "clear",
@@ -561,10 +539,6 @@ export function RunCommandMenuBody(props: {
return
}
if (item.action === "skill") {
props.onSkill()
return
}
if (item.action === "subagent") {
props.onSubagent()
@@ -1052,65 +1026,6 @@ export function RunQueuedPromptSelectBody(props: {
)
}
export function RunSkillSelectBody(props: {
theme: Accessor<RunFooterTheme>
commands: Accessor<RunCommand[] | undefined>
onClose: () => void
onSelect: (name: string) => void
mono?: boolean
}) {
const entries = createMemo<SkillEntry[]>(() =>
(props.commands() ?? [])
.filter((item) => item.source === "skill")
.map((item) => ({
category: "",
display: item.name,
description: item.description?.replace(/\s+/g, " ").trim() || undefined,
keywords: `skill ${item.name} ${item.description ?? ""}`,
name: item.name,
}))
.sort((a, b) => a.display.localeCompare(b.display)),
)
const controller = createSearchablePanelController({
entries,
limit: PANEL_LIST_ROWS,
onClose: props.onClose,
onSelect: (item) => props.onSelect(item.name),
})
return (
<PanelShell
title="Skills"
layout={controller.layout()}
query={controller.query()}
count={controller.items().length}
total={entries().length}
placeholder="Search"
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
mono={props.mono}
>
<RunFooterMenu
theme={props.theme}
items={controller.items}
selected={controller.menu.selected}
offset={controller.menu.offset}
rows={controller.menu.limit}
limit={controller.menu.limit()}
compact={controller.layout().compact}
empty={props.commands() ? "No skills found" : "Skills loading"}
border={false}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
grouped={false}
background
mono={props.mono}
/>
</PanelShell>
)
}
export function RunVariantSelectBody(props: {
theme: Accessor<RunFooterTheme>
variants: Accessor<string[]>
+6 -81
View File
@@ -104,15 +104,10 @@ type Auto = RunFooterMenuItem & {
type SlashOption = RunFooterMenuItem & {
kind: "slash"
name: string
action?: "skill-menu" | "editor" | "settings"
action?: "editor" | "settings"
}
type SkillOption = RunFooterMenuItem & {
kind: "skill"
id: string
}
type PromptOption = Auto | SlashOption | SkillOption
type PromptOption = Auto | SlashOption
type MenuMode = false | "mention" | "slash"
@@ -141,7 +136,6 @@ type PromptInput = {
onInputClear: () => void
onExitRequest?: () => boolean
onExit: () => void
onSkillMenu: () => void
onSettings: () => void
onRows: (rows: number) => void
onStatus: (text: string) => void
@@ -514,19 +508,7 @@ export function createPromptState(input: PromptInput): PromptState {
}),
)
const mentionOptions = createMemo(() => [...agents(), ...files(), ...references()])
const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
const skillOptions = createMemo<SkillOption[]>(() =>
skillCommands().map((item) => ({
kind: "skill",
id: item.name,
display: `/${item.name}`,
description: item.description,
})),
)
const hasSkillsCommand = createMemo(() =>
(input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"),
)
const slashOptions = createMemo<Array<SlashOption | SkillOption>>(() => {
const slashOptions = createMemo<SlashOption[]>(() => {
const builtins = [
{
kind: "slash",
@@ -552,26 +534,9 @@ export function createPromptState(input: PromptInput): PromptState {
{ kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption,
]
const hidden = new Set(builtins.map((item) => item.name))
const showSkillMenu = !shell() && skillCommands().length > 0 && !hasSkillsCommand()
if (showSkillMenu) {
hidden.add("skills")
}
return [
...skillOptions(),
...(showSkillMenu
? [
{
kind: "slash",
action: "skill-menu" as const,
name: "skills",
display: "/skills",
description: "browse available skills",
} satisfies SlashOption,
]
: []),
...(input.commands() ?? [])
.filter((item) => item.source !== "skill" && !hidden.has(item.name))
.filter((item) => !hidden.has(item.name))
.map(
(item) =>
({
@@ -585,7 +550,7 @@ export function createPromptState(input: PromptInput): PromptState {
].sort((a, b) => a.display.localeCompare(b.display))
})
const options = createMemo<PromptOption[]>(() => {
const mixed: PromptOption[] = mode() === "slash" ? (at() === 0 ? slashOptions() : skillOptions()) : mentionOptions()
const mixed: PromptOption[] = mode() === "slash" ? slashOptions() : mentionOptions()
if (!query()) {
return mixed
}
@@ -602,7 +567,7 @@ export function createPromptState(input: PromptInput): PromptState {
return fuzzysort
.go(next, mixed, {
keys: [
(item) => (item.kind === "mention" ? item.value : item.kind === "skill" ? item.id : item.name).trimEnd(),
(item) => (item.kind === "mention" ? item.value : item.name).trimEnd(),
"display",
"description",
],
@@ -1073,39 +1038,6 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
if (next.kind === "skill") {
if (parts.some((part) => part.type === "skill" && part.id === next.id)) {
cancelAutocomplete()
return
}
const cursor = area.cursorOffset
const tail = displayCharAt(area.plainText, cursor)
const append = `/${next.id}${tail === " " ? "" : " "}`
area.cursorOffset = at()
const start = area.logicalCursor
area.cursorOffset = cursor
const end = area.logicalCursor
area.deleteRange(start.row, start.col, end.row, end.col)
area.insertText(append)
const text = `/${next.id}`
const startOffset = at()
const endOffset = startOffset + stringWidth(text)
const part: Extract<RunPromptPart, { type: "skill" }> = {
type: "skill",
id: next.id,
source: { start: startOffset, end: endOffset, value: text },
}
const id = area.extmarks.create({ start: startOffset, end: endOffset, virtual: true, typeId: type })
marks.set(id, parts.length)
parts.push(part)
hide()
syncDraft()
scheduleRows()
area.focus()
return
}
if (next.kind === "slash") {
if (next.action === "editor") {
void openEditor({
@@ -1114,12 +1046,6 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
if (next.action === "skill-menu") {
cancelAutocomplete()
input.onSkillMenu()
return
}
if (next.action === "settings" && !shell()) {
cancelAutocomplete()
input.onSettings()
@@ -1463,7 +1389,6 @@ export function createPromptState(input: PromptInput): PromptState {
if (
delivery === "queue" &&
(next.mode === "shell" ||
command?.source === "skill" ||
isNewCommand(next.text) ||
isCompactCommand(next.text) ||
isExitCommand(next.text) ||
-35
View File
@@ -21,7 +21,6 @@ import {
RunModelSelectBody,
RunQueuedPromptSelectBody,
RunSettingsBody,
RunSkillSelectBody,
RunSubagentSelectBody,
RunVariantSelectBody,
} from "./footer.command"
@@ -149,13 +148,11 @@ export function RunFooterView(props: RunFooterViewProps) {
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
const queue = createMemo(() => queuedPrompts().filter((item) => item.delivery === "queue"))
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
const selectingQueued = createMemo(() => active().type === "prompt" && route().type === "queued-menu")
const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
const commanding = createMemo(() => active().type === "prompt" && route().type === "command")
const skilling = createMemo(() => active().type === "prompt" && route().type === "skill")
const agenting = createMemo(() => active().type === "prompt" && route().type === "agent")
const modeling = createMemo(() => active().type === "prompt" && route().type === "model")
const varianting = createMemo(() => active().type === "prompt" && route().type === "variant")
@@ -167,7 +164,6 @@ export function RunFooterView(props: RunFooterViewProps) {
selectingQueued() ||
selectingSubagent() ||
commanding() ||
skilling() ||
agenting() ||
modeling() ||
varianting() ||
@@ -298,14 +294,6 @@ export function RunFooterView(props: RunFooterViewProps) {
openRoute({ type: "agent" })
}
const openSkillMenu = () => {
if (props.commands() && skills().length === 0) {
return
}
openRoute({ type: "skill" })
}
const openVariant = () => {
openRoute({ type: "variant" })
}
@@ -398,7 +386,6 @@ export function RunFooterView(props: RunFooterViewProps) {
onInputClear: props.onInputClear,
onExitRequest: props.onExitRequest,
onExit: props.onExit,
onSkillMenu: openSkillMenu,
onSettings: openSettings,
onRows: setPromptRows,
onStatus: props.onStatus,
@@ -683,7 +670,6 @@ export function RunFooterView(props: RunFooterViewProps) {
const current = route()
if (
current.type !== "command" &&
current.type !== "skill" &&
current.type !== "agent" &&
current.type !== "model" &&
current.type !== "variant" &&
@@ -830,7 +816,6 @@ export function RunFooterView(props: RunFooterViewProps) {
closePanel()
void composer.openEditor()
}}
onSkill={openSkillMenu}
onSubagent={openSubagentMenu}
onQueued={openQueuedMenu}
onVariant={openVariant}
@@ -860,26 +845,6 @@ export function RunFooterView(props: RunFooterViewProps) {
mono={props.mono}
/>
</Match>
<Match when={skilling()}>
<RunSkillSelectBody
theme={theme}
commands={props.commands}
onClose={closePanel}
onSelect={(name) => {
composer.replacePrompt({
text: `/${name} `,
parts: [],
command: {
name,
arguments: "",
source: "skill",
},
})
closePanel()
}}
mono={props.mono}
/>
</Match>
<Match when={agenting()}>
<RunAgentSelectBody
theme={theme}
+1 -13
View File
@@ -172,19 +172,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
break
}
const emittedUser = sent.mode !== "shell" && sent.command?.source === "skill" && !!sent.text.trim()
if (emittedUser) {
const commit = {
kind: "user",
text: sent.text,
phase: "start",
source: "system",
messageID: sent.messageID,
} as const
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
}
input.onSend?.(sent, emittedUser)
input.onSend?.(sent, false)
if (state.closed) {
break
+1 -19
View File
@@ -1789,8 +1789,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return {
async admitPromptTurn(next, delivery) {
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
throw new Error("This prompt cannot be queued")
if (next.prompt.mode === "shell") throw new Error("This prompt cannot be queued")
if (!state.connected) throw new Error("Event stream is reconnecting")
const client = sdk
if (!next.prompt.command && next.agent)
@@ -1822,23 +1821,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (!messageID) throw new Error("Prompt message ID is required")
const command = next.prompt.command
if (command?.source === "skill") {
if (next.agent)
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
input.trace?.write("send.skill", { sessionID: input.sessionID, messageID, skill: command.name })
await runTurnWait(
next,
messageID,
client,
() =>
client.session.skill(
{ sessionID: input.sessionID, id: messageID, skill: command.name },
{ signal: next.signal },
),
admitted,
)
return
}
if (command) {
await admitPrompt(next, client, next.prompt.delivery ?? "steer")
admitted?.()
-2
View File
@@ -85,7 +85,6 @@ export type RunPrompt = {
command?: {
name: string
arguments: string
// Catalog source of the matched slash entry ("skill" routes to session.skill).
source?: string
}
}
@@ -295,7 +294,6 @@ export type FooterPromptRoute =
| { type: "subagent-menu" }
| { type: "subagent"; sessionID: string }
| { type: "command" }
| { type: "skill" }
| { type: "agent" }
| { type: "model" }
| { type: "variant" }
-301
View File
@@ -14,7 +14,6 @@ import {
RunCommandMenuBody,
RunModelSelectBody,
RunSettingsBody,
RunSkillSelectBody,
RunSubagentSelectBody,
RunVariantSelectBody,
} from "../../src/mini/footer.command"
@@ -836,7 +835,6 @@ test("direct command panel renders grouped actions without catalog commands", as
const [commands] = createSignal<RunCommand[] | undefined>([
command({ name: "review", description: "Review code" }),
command({ name: "deploy", description: "Deploy prompt", source: "mcp" }),
command({ name: "internal", description: "Skill command", source: "skill" }),
])
const [subagents] = createSignal([])
const [variants] = createSignal(["high", "minimal"])
@@ -856,7 +854,6 @@ test("direct command panel renders grouped actions without catalog commands", as
onAgent={() => {}}
onModel={() => {}}
onEditor={() => {}}
onSkill={() => {}}
onSubagent={() => {}}
onQueued={() => {}}
onVariant={() => {}}
@@ -884,14 +881,11 @@ test("direct command panel renders grouped actions without catalog commands", as
expect(frame).toContain("Search")
expect(frame).toContain("Session")
expect(frame).toContain("Agent")
expect(frame).toContain("Prompt")
expect(frame).toContain("Open editor")
expect(frame).toContain("/editor")
expect(frame).toContain("Show status")
expect(frame).toContain("Compact session")
expect(frame).toContain("/compact")
expect(frame).toContain("Skills")
expect(frame).toContain("/skills")
expect(frame.match(/\bAgent\b/g)?.length).toBe(1)
expect(frame).not.toContain("┌")
expect(frame).not.toContain("┃")
@@ -1054,93 +1048,6 @@ test.each([false, true])("settings change preferences and preview the work spinn
}
})
test("direct skill panel renders searchable skill list", async () => {
const [commands] = createSignal<RunCommand[] | undefined>([
command({ name: "review", description: "Review code" }),
command({ name: "internal", description: "Skill command", source: "skill" }),
command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
])
const selected: string[] = []
const app = await testRender(
() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunSkillSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
commands={commands}
onClose={() => {}}
onSelect={(name) => {
selected.push(name)
}}
/>
</box>
),
{
width: 100,
height: RUN_COMMAND_PANEL_ROWS,
},
)
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("Skills")
expect(frame).toContain("Search")
expect(frame).toContain("internal")
expect(frame).not.toContain("/internal")
expect(frame).toContain("formatter")
expect(frame).toContain("Apply formatter fixes")
expect(frame).not.toContain("review")
await app.mockInput.typeText("format")
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("internal")
app.mockInput.pressEnter()
expect(selected).toEqual(["formatter"])
} finally {
app.renderer.destroy()
}
})
test("direct skill panel truncates long descriptions from the end", async () => {
const [commands] = createSignal<RunCommand[] | undefined>([
command({
name: "terminal-control",
description:
"Control and test terminal applications, REPLs, interactive CLIs, shell processes, OpenTUI applications, or other terminal-backed workflows.",
source: "skill",
}),
])
const app = await testRender(
() => (
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
<RunSkillSelectBody
theme={() => RUN_THEME_FALLBACK.footer}
commands={commands}
onClose={() => {}}
onSelect={() => {}}
/>
</box>
),
{
width: 100,
height: RUN_COMMAND_PANEL_ROWS,
},
)
try {
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("terminal-control")
expect(frame).toContain("Control and test terminal applications")
expect(frame).not.toMatch(/application(?:…|\.\.\.)ocess/)
} finally {
app.renderer.destroy()
}
})
test("direct command panel shows subagent entry when available", async () => {
const [commands] = createSignal<RunCommand[] | undefined>([])
const [subagents] = createSignal([subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })])
@@ -1160,7 +1067,6 @@ test("direct command panel shows subagent entry when available", async () => {
onAgent={() => {}}
onModel={() => {}}
onEditor={() => {}}
onSkill={() => {}}
onSubagent={() => {}}
onQueued={() => {}}
onVariant={() => {}}
@@ -1211,7 +1117,6 @@ test("direct command panel keeps completed subagents available", async () => {
onAgent={() => {}}
onModel={() => {}}
onEditor={() => {}}
onSkill={() => {}}
onSubagent={() => {}}
onQueued={() => {}}
onVariant={() => {}}
@@ -1627,35 +1532,6 @@ test("direct footer submits slash autocomplete selections without dispatching sh
}
})
test("direct footer slash autocomplete keeps a real skills command", async () => {
const submits: RunPrompt[] = []
const app = await renderFooter({
commands: [
command({ name: "skills", description: "Run the real skills command" }),
command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
],
onSubmit(prompt) {
submits.push(prompt)
return true
},
})
try {
await app.renderOnce()
"/skills".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([
{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" }, delivery: "steer" },
])
expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
} finally {
app.cleanup()
}
})
test("direct footer closes settings with ctrl-c instead of arming exit", async () => {
const app = await renderFooter({ height: 20 })
@@ -1677,11 +1553,6 @@ test("direct footer closes settings with ctrl-c instead of arming exit", async (
})
test("selectedCommand validates the bound command and refreshes its arguments", () => {
expect(selectedCommand("/opencode-ts", { name: "opencode-ts", arguments: "", source: "skill" })).toEqual({
name: "opencode-ts",
arguments: "",
source: "skill",
})
expect(selectedCommand("/deploy prod", { name: "deploy", arguments: "" })).toEqual({
name: "deploy",
arguments: "prod",
@@ -1689,178 +1560,6 @@ test("selectedCommand validates the bound command and refreshes its arguments",
expect(selectedCommand("/other", { name: "deploy", arguments: "" })).toBeUndefined()
})
test("direct footer tags skill slash submissions with their catalog source", async () => {
const submits: RunPrompt[] = []
const app = await renderFooter({
commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
onSubmit(prompt) {
submits.push(prompt)
return true
},
})
try {
await app.renderOnce()
"/formatter src".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([
{
text: "/formatter src",
parts: [],
command: { name: "formatter", arguments: "src", source: "skill" },
delivery: "steer",
},
])
} finally {
app.cleanup()
}
})
test("direct footer submits a selected leading skill as a prompt attachment", async () => {
const submits: RunPrompt[] = []
const app = await renderFooter({
commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
onSubmit(prompt) {
submits.push(prompt)
return true
},
})
try {
await app.renderOnce()
"/forma".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
"src".split("").forEach((key) => app.mockInput.pressKey(key))
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([
{
text: "/formatter src",
parts: [
{
type: "skill",
id: "formatter",
source: { start: 0, end: 10, value: "/formatter" },
},
],
delivery: "steer",
},
])
} finally {
app.cleanup()
}
})
test("direct footer preserves a selected skill after wide text", async () => {
const submits: RunPrompt[] = []
const app = await renderFooter({
commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
onSubmit(prompt) {
submits.push(prompt)
return true
},
})
try {
await app.renderOnce()
"中 /forma".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits[0]?.parts).toEqual([
{
type: "skill",
id: "formatter",
source: { start: 3, end: 13, value: "/formatter" },
},
])
} finally {
app.cleanup()
}
})
// OpenTUI currently segfaults Bun while tearing down this composer-to-skill-panel transition.
// Re-enable after the upstream renderer teardown fix lands.
test.skip("direct footer skill picker inserts an editable bound skill command", async () => {
const submits: RunPrompt[] = []
const app = await renderFooter({
commands: [command({ name: "new", description: "Skill named new", source: "skill" })],
onSubmit(prompt) {
submits.push(prompt)
return true
},
})
try {
await app.renderOnce()
"/skills".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Skill named new")
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([])
expect(app.captureCharFrame()).toContain("/new")
"task".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([
{ text: "/new task", parts: [], command: { name: "new", arguments: "task", source: "skill" } },
])
} finally {
app.cleanup()
}
})
// OpenTUI currently segfaults Bun while tearing down this skill-panel close transition.
// Re-enable after the upstream renderer teardown fix lands.
test.skip("direct footer clears the synthetic skills draft when the panel closes", async () => {
const submits: RunPrompt[] = []
const app = await renderFooter({
commands: [command({ name: "formatter", description: "Apply formatter fixes", source: "skill" })],
onSubmit(prompt) {
submits.push(prompt)
return true
},
})
try {
await app.renderOnce()
"/skills".split("").forEach((key) => app.mockInput.pressKey(key))
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Apply formatter fixes")
app.mockInput.pressKey("c", { ctrl: true })
await app.renderOnce()
app.mockInput.pressEnter()
await app.renderOnce()
expect(submits).toEqual([])
expect(app.captureCharFrame()).not.toContain("/skills")
} finally {
app.cleanup()
}
})
test("direct footer counts queued and steering work while running", async () => {
const app = await renderFooter({
width: 160,
@@ -251,27 +251,6 @@ describe("run runtime queue", () => {
expect(ui.commits).toEqual([])
})
test("skill commands retain their local input echo", async () => {
const ui = createFooterApiFixture()
const task = runPromptQueue({
footer: ui.api,
onSend: (_prompt, emittedUser) => expect(emittedUser).toBe(true),
run: async () => ui.api.close(),
})
ui.submit({ text: "/review", parts: [], command: { name: "review", arguments: "", source: "skill" } })
await task
expect(ui.commits).toEqual([
{
kind: "user",
text: "/review",
phase: "start",
source: "system",
messageID: expect.any(String),
},
])
})
test("demo commands own their local input echo", async () => {
const { createRunDemo } = await import("../../src/mini/demo")
const ui = createFooterApiFixture()
@@ -3413,70 +3413,6 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("routes skill prompts through v2.session.skill and settles without promotion", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
footer: ui.api,
})
let request: Parameters<OpenCodeClient["session"]["skill"]>[0] | undefined
const command = spyOn(client.session, "command")
const prompt = spyOn(client.session, "prompt")
spyOn(client.session, "skill").mockImplementation((input) => {
request = input
queueMicrotask(() => {
events.push({
id: "evt_skill",
created: 0,
type: "session.skill.activated",
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
id: input.skill ?? "tigerstyle",
name: input.skill ?? "tigerstyle",
text: "skill instructions",
},
})
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
})
})
return ok(undefined) as never
})
await transport.runPromptTurn({
agent: "review",
model: undefined,
variant: undefined,
prompt: {
messageID: "msg_skill",
text: "/tigerstyle",
parts: [],
command: { name: "tigerstyle", arguments: "", source: "skill" },
},
files: [],
includeFiles: true,
})
expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything())
expect(request).toMatchObject({ sessionID: "ses_1", id: "msg_skill", skill: "tigerstyle" })
expect(command).not.toHaveBeenCalled()
expect(prompt).not.toHaveBeenCalled()
expect(ui.commits).toContainEqual(
expect.objectContaining({ kind: "system", text: '→ Skill "tigerstyle"', messageID: "msg_skill" }),
)
await transport.close()
})
test("sends inline skill attachments with a normal prompt", async () => {
const events = feed()
events.push(connected())
+2 -17
View File
@@ -16864,12 +16864,6 @@
},
"branch": {
"type": "string"
},
"description": {
"type": "string"
},
"hidden": {
"type": "boolean"
}
},
"required": ["type", "repository"],
@@ -16906,12 +16900,6 @@
},
"path": {
"type": "string"
},
"description": {
"type": "string"
},
"hidden": {
"type": "boolean"
}
},
"required": ["type", "path"],
@@ -18850,20 +18838,17 @@
"description": {
"type": "string"
},
"slash": {
"type": "boolean"
},
"autoinvoke": {
"type": "boolean"
},
"location": {
"path": {
"type": "string"
},
"content": {
"type": "string"
}
},
"required": ["id", "name", "location", "content"],
"required": ["id", "name", "path", "content"],
"additionalProperties": false
},
"SkillNotFoundErrorEncoded": {
+2 -17
View File
@@ -16864,12 +16864,6 @@
},
"branch": {
"type": "string"
},
"description": {
"type": "string"
},
"hidden": {
"type": "boolean"
}
},
"required": ["type", "repository"],
@@ -16906,12 +16900,6 @@
},
"path": {
"type": "string"
},
"description": {
"type": "string"
},
"hidden": {
"type": "boolean"
}
},
"required": ["type", "path"],
@@ -18850,20 +18838,17 @@
"description": {
"type": "string"
},
"slash": {
"type": "boolean"
},
"autoinvoke": {
"type": "boolean"
},
"location": {
"path": {
"type": "string"
},
"content": {
"type": "string"
}
},
"required": ["id", "name", "location", "content"],
"required": ["id", "name", "path", "content"],
"additionalProperties": false
},
"SkillNotFoundErrorEncoded": {