feat(core): register built-in skill

This commit is contained in:
Dax Raad 2026-06-03 18:02:57 -04:00
commit 8a97cb55d3
18 changed files with 159 additions and 41 deletions

View file

@ -18,9 +18,14 @@ export const Plugin = PluginV2.define({
const skill = yield* SkillV2.Service const skill = yield* SkillV2.Service
const transform = yield* skill.transform() const transform = yield* skill.transform()
const entries = yield* config.entries() const entries = yield* config.entries()
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
yield* transform((editor) => { yield* transform((editor) => {
for (const directory of directories) {
editor.source(new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }))
editor.source(new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
}
for (const item of items) { for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
editor.source(new SkillV2.UrlSource({ type: "url", url: item })) editor.source(new SkillV2.UrlSource({ type: "url", url: item }))

4
packages/core/src/markdown.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.md" {
const content: string
export default content
}

View file

@ -19,6 +19,7 @@ import { PluginV2 } from "../plugin"
import { AccountPlugin } from "./account" import { AccountPlugin } from "./account"
import { AgentPlugin } from "./agent" import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command" import { CommandPlugin } from "./command"
import { SkillPlugin } from "./skill"
import { ConfigProviderPlugin } from "../config/plugin/provider" import { ConfigProviderPlugin } from "../config/plugin/provider"
import { EnvPlugin } from "./env" import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev" import { ModelsDevPlugin } from "./models-dev"
@ -94,6 +95,7 @@ export const layer = Layer.effect(
yield* add(AccountPlugin) yield* add(AccountPlugin)
yield* add(AgentPlugin.Plugin) yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin) yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
for (const item of ProviderPlugins) { for (const item of ProviderPlugins) {
yield* add(item) yield* add(item)
} }

View file

@ -0,0 +1,30 @@
export * as SkillPlugin from "./skill"
import { Effect } from "effect"
import { PluginV2 } from "../plugin"
import { AbsolutePath } from "../schema"
import { SkillV2 } from "../skill"
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./skill/customize-opencode.md" with { type: "text" }
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("skill"),
effect: Effect.gen(function* () {
const skill = yield* SkillV2.Service
const transform = yield* skill.transform()
yield* transform((editor) => {
editor.source(
new SkillV2.EmbeddedSource({
type: "embedded",
skill: new SkillV2.Info({
name: "customize-opencode",
description:
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
location: AbsolutePath.make("/builtin/customize-opencode.md"),
content: CUSTOMIZE_OPENCODE_SKILL_BODY,
}),
}),
)
})
}),
})

View file

@ -1,6 +1,6 @@
<!-- <!--
Built-in skill. Name and description are registered in code at Built-in skill. Name and description are registered in code at
packages/opencode/src/skill/index.ts (see CUSTOMIZE_OPENCODE_SKILL_NAME packages/core/src/plugin/skill.ts
and CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION). The body below becomes the and CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION). The body below becomes the
skill's content. skill's content.
--> -->

View file

@ -21,17 +21,23 @@ export class UrlSource extends Schema.Class<UrlSource>("SkillV2.UrlSource")({
url: Schema.String, url: Schema.String,
}) {} }) {}
export const Source = Schema.Union([DirectorySource, UrlSource]).pipe( export class EmbeddedSource extends Schema.Class<EmbeddedSource>("SkillV2.EmbeddedSource")({
type: Schema.Literal("embedded"),
skill: Schema.suspend(() => Info),
}) {}
export const Source = Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe(
Schema.toTaggedUnion("type"), Schema.toTaggedUnion("type"),
withStatics(() => ({ withStatics(() => ({
equals: (a: DirectorySource | UrlSource, b: DirectorySource | UrlSource) => { equals: (a: DirectorySource | UrlSource | EmbeddedSource, b: DirectorySource | UrlSource | EmbeddedSource) => {
if (a.type !== b.type) return false if (a.type !== b.type) return false
if (a.type === "directory" && b.type === "directory") return a.path === b.path if (a.type === "directory" && b.type === "directory") return a.path === b.path
if (a.type === "url" && b.type === "url") return a.url === b.url if (a.type === "url" && b.type === "url") return a.url === b.url
if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name
return false return false
}, },
key: (source: DirectorySource | UrlSource) => key: (source: DirectorySource | UrlSource | EmbeddedSource) =>
source.type === "directory" ? `directory:${source.path}` : `url:${source.url}`, source.type === "directory" ? `directory:${source.path}` : source.type === "url" ? `url:${source.url}` : `embedded:${source.skill.name}`,
})), })),
) )
export type Source = typeof Source.Type export type Source = typeof Source.Type
@ -89,6 +95,7 @@ export const layer = Layer.effect(
const load = Effect.fn("SkillV2.load")(function* (source: Source) { const load = Effect.fn("SkillV2.load")(function* (source: Source) {
const skills: Info[] = [] const skills: Info[] = []
if (source.type === "embedded") return [source.skill]
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url) const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
for (const directory of directories) { for (const directory of directories) {
const files = yield* fs const files = yield* fs

View file

@ -33,6 +33,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
Config.Service.of({ Config.Service.of({
entries: () => entries: () =>
Effect.succeed([ Effect.succeed([
new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
new Config.Document({ new Config.Document({
type: "document", type: "document",
info: decode({ info: decode({
@ -56,6 +57,8 @@ describe("ConfigSkillPlugin.Plugin", () => {
) )
expect(sources).toEqual([ expect(sources).toEqual([
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skill")) }),
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skills")) }),
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
new SkillV2.DirectorySource({ new SkillV2.DirectorySource({
type: "directory", type: "directory",

View file

@ -0,0 +1,32 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { testEffect } from "../lib/effect"
const it = testEffect(
SkillV2.layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(SkillDiscovery.defaultLayer),
Layer.provideMerge(AgentV2.locationLayer),
),
)
describe("SkillPlugin.Plugin", () => {
it.effect("registers the built-in customize-opencode skill", () =>
Effect.gen(function* () {
const skill = yield* SkillV2.Service
yield* SkillPlugin.Plugin.effect.pipe(Effect.provideService(SkillV2.Service, skill))
expect(yield* skill.list()).toContainEqual(
expect.objectContaining({
name: "customize-opencode",
description: expect.stringContaining("opencode's own configuration"),
}),
)
}),
)
})

View file

@ -5,6 +5,7 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors" import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization" import { V2Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
import { data } from "./response"
export const MessagesQuery = Schema.Struct({ export const MessagesQuery = Schema.Struct({
...WorkspaceRoutingQueryFields, ...WorkspaceRoutingQueryFields,
@ -29,13 +30,13 @@ export const MessageGroup = HttpApiGroup.make("v2.message")
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: MessagesQuery, query: MessagesQuery,
success: Schema.Struct({ success: data(Schema.Struct({
items: Schema.Array(SessionMessage.Message), items: Schema.Array(SessionMessage.Message),
cursor: Schema.Struct({ cursor: Schema.Struct({
previous: Schema.String.pipe(Schema.optional), previous: Schema.String.pipe(Schema.optional),
next: Schema.String.pipe(Schema.optional), next: Schema.String.pipe(Schema.optional),
}), }),
}).annotate({ identifier: "V2SessionMessagesResponse" }), }).annotate({ identifier: "V2SessionMessagesResponse" })),
error: [InvalidCursorError, SessionNotFoundError, UnknownError], error: [InvalidCursorError, SessionNotFoundError, UnknownError],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({

View file

@ -8,6 +8,7 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/un
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors" import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization" import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location" import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
import { data } from "./response"
export const PermissionGroup = HttpApiGroup.make("v2.permission") export const PermissionGroup = HttpApiGroup.make("v2.permission")
.add( .add(
@ -32,7 +33,7 @@ export const SessionPermissionGroup = HttpApiGroup.make("v2.session.permission")
.add( .add(
HttpApiEndpoint.get("sessionPermissionRequests", "/api/session/:sessionID/permission/request", { HttpApiEndpoint.get("sessionPermissionRequests", "/api/session/:sessionID/permission/request", {
params: { sessionID: SessionV2.ID }, params: { sessionID: SessionV2.ID },
success: Schema.Array(PermissionV2.Request), success: data(Schema.Array(PermissionV2.Request)),
error: SessionNotFoundError, error: SessionNotFoundError,
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@ -68,7 +69,7 @@ export const PermissionSavedGroup = HttpApiGroup.make("v2.permission.saved")
.add( .add(
HttpApiEndpoint.get("savedPermissions", "/api/permission/saved", { HttpApiEndpoint.get("savedPermissions", "/api/permission/saved", {
query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }), query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }),
success: Schema.Array(PermissionSaved.Info), success: data(Schema.Array(PermissionSaved.Info)),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.permission.saved.list", identifier: "v2.permission.saved.list",

View file

@ -0,0 +1,9 @@
import { Schema } from "effect"
export function data<S extends Schema.Top>(schema: S) {
return Schema.Struct({ data: schema })
}
export function make<A>(data: A) {
return { data }
}

View file

@ -16,6 +16,7 @@ import {
} from "../../errors" } from "../../errors"
import { V2Authorization } from "../../middleware/authorization" import { V2Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQuery } from "../../middleware/workspace-routing" import { WorkspaceRoutingQuery } from "../../middleware/workspace-routing"
import { data } from "./response"
const SessionsQueryFields = { const SessionsQueryFields = {
workspace: WorkspaceV2.ID.pipe(Schema.optional), workspace: WorkspaceV2.ID.pipe(Schema.optional),
@ -88,13 +89,13 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
.add( .add(
HttpApiEndpoint.get("sessions", "/api/session", { HttpApiEndpoint.get("sessions", "/api/session", {
query: SessionsQuery, query: SessionsQuery,
success: Schema.Struct({ success: data(Schema.Struct({
items: Schema.Array(SessionV2.Info), items: Schema.Array(SessionV2.Info),
cursor: Schema.Struct({ cursor: Schema.Struct({
previous: SessionsCursor.pipe(Schema.optional), previous: SessionsCursor.pipe(Schema.optional),
next: SessionsCursor.pipe(Schema.optional), next: SessionsCursor.pipe(Schema.optional),
}), }),
}).annotate({ identifier: "V2SessionsResponse" }), }).annotate({ identifier: "V2SessionsResponse" })),
error: [InvalidCursorError, InvalidRequestError], error: [InvalidCursorError, InvalidRequestError],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@ -113,7 +114,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
prompt: Prompt, prompt: Prompt,
delivery: SessionV2.Delivery.pipe(Schema.optional), delivery: SessionV2.Delivery.pipe(Schema.optional),
}), }),
success: SessionMessage.Message, success: data(SessionMessage.Message),
error: [SessionNotFoundError, ServiceUnavailableError], error: [SessionNotFoundError, ServiceUnavailableError],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
@ -155,7 +156,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { HttpApiEndpoint.get("context", "/api/session/:sessionID/context", {
params: { sessionID: SessionID }, params: { sessionID: SessionID },
query: WorkspaceRoutingQuery, query: WorkspaceRoutingQuery,
success: Schema.Array(SessionMessage.Message), success: data(Schema.Array(SessionMessage.Message)),
error: [SessionNotFoundError, UnknownError], error: [SessionNotFoundError, UnknownError],
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({

View file

@ -5,6 +5,7 @@ import * as DateTime from "effect/DateTime"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api" import { InstanceHttpApi } from "../../api"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors" import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
import { make } from "../../groups/v2/response"
const DefaultMessagesLimit = 50 const DefaultMessagesLimit = 50
@ -75,13 +76,13 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message
) )
const first = messages[0] const first = messages[0]
const last = messages.at(-1) const last = messages.at(-1)
return { return make({
items: messages, items: messages,
cursor: { cursor: {
previous: first ? cursor.encode(first, order, "previous") : undefined, previous: first ? cursor.encode(first, order, "previous") : undefined,
next: last ? cursor.encode(last, order, "next") : undefined, next: last ? cursor.encode(last, order, "next") : undefined,
}, },
} })
}), }),
) )
}), }),

View file

@ -10,6 +10,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api" import { InstanceHttpApi } from "../../api"
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors" import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
import { response } from "../../groups/v2/location" import { response } from "../../groups/v2/location"
import { make } from "../../groups/v2/response"
function missingRequest(id: PermissionV2.ID) { function missingRequest(id: PermissionV2.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` }) return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
@ -62,7 +63,7 @@ export const sessionPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "
"sessionPermissionRequests", "sessionPermissionRequests",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
return yield* withSessionPermission(ctx.params.sessionID, (permission) => return yield* withSessionPermission(ctx.params.sessionID, (permission) =>
permission.forSession(ctx.params.sessionID), permission.forSession(ctx.params.sessionID).pipe(Effect.map(make)),
) )
}), }),
) )
@ -92,7 +93,7 @@ export const savedPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2
.handle( .handle(
"savedPermissions", "savedPermissions",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
return yield* saved.list({ projectID: ctx.query.projectID }) return make(yield* saved.list({ projectID: ctx.query.projectID }))
}), }),
) )
.handle( .handle(

View file

@ -4,6 +4,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api" import { InstanceHttpApi } from "../../api"
import { SessionsCursor } from "../../groups/v2/session" import { SessionsCursor } from "../../groups/v2/session"
import { InvalidCursorError, ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../../errors" import { InvalidCursorError, ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../../errors"
import { make } from "../../groups/v2/response"
const DefaultSessionsLimit = 50 const DefaultSessionsLimit = 50
@ -28,7 +29,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
}) })
const first = sessions[0] const first = sessions[0]
const last = sessions.at(-1) const last = sessions.at(-1)
return { return make({
items: sessions, items: sessions,
cursor: { cursor: {
previous: first previous: first
@ -52,13 +53,13 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
}) })
: undefined, : undefined,
}, },
} })
}), }),
) )
.handle( .handle(
"prompt", "prompt",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
return yield* session return make(yield* session
.prompt({ .prompt({
sessionID: ctx.params.sessionID, sessionID: ctx.params.sessionID,
prompt: ctx.payload.prompt, prompt: ctx.payload.prompt,
@ -81,7 +82,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
}), }),
), ),
), ),
) ))
}), }),
) )
.handle( .handle(
@ -135,7 +136,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
.handle( .handle(
"context", "context",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
return yield* session.context(ctx.params.sessionID).pipe( return make(yield* session.context(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) => Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail( Effect.fail(
new SessionNotFoundError({ new SessionNotFoundError({
@ -158,7 +159,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
), ),
) )
}), }),
) ))
}), }),
) )
}), }),

View file

@ -15,7 +15,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { Glob } from "@opencode-ai/core/util/glob" import { Glob } from "@opencode-ai/core/util/glob"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Discovery } from "./discovery" import { Discovery } from "./discovery"
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } import CUSTOMIZE_OPENCODE_SKILL_BODY from "../../../core/src/plugin/skill/customize-opencode.md" with { type: "text" }
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
const log = Log.create({ service: "skill" }) const log = Log.create({ service: "skill" })

View file

@ -42,6 +42,22 @@ function cursor(input: Record<string, unknown>) {
return Buffer.from(JSON.stringify(input)).toString("base64url") return Buffer.from(JSON.stringify(input)).toString("base64url")
} }
function data(validate: (value: any) => void) {
return (body: any) => {
object(body)
validate(body.data)
}
}
function locationData(validate: (value: any) => void) {
return (body: any) => {
object(body)
object(body.location)
object(body.location.project)
validate(body.data)
}
}
const scenarios: Scenario[] = [ const scenarios: Scenario[] = [
http.protected http.protected
.get("/global/health", "global.health") .get("/global/health", "global.health")
@ -608,19 +624,19 @@ const scenarios: Scenario[] = [
check(auth.test === undefined, "auth remove should delete provider from isolated auth file") check(auth.test === undefined, "auth remove should delete provider from isolated auth file")
}), }),
), ),
http.protected.get("/api/model", "v2.model.list").json(200, array), http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)),
http.protected.get("/api/provider", "v2.provider.list").json(200, array), http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)),
http.protected http.protected
.get("/api/fs/read", "v2.fs.read") .get("/api/fs/read", "v2.fs.read")
.seeded((ctx) => ctx.file("hello.txt", "hello\n")) .seeded((ctx) => ctx.file("hello.txt", "hello\n"))
.at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() })) .at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() }))
.json(200, object), .json(200, locationData(object)),
http.protected.get("/api/fs/list", "v2.fs.list").json(200, array), http.protected.get("/api/fs/list", "v2.fs.list").json(200, locationData(array)),
http.protected http.protected
.get("/api/provider/{providerID}", "v2.provider.get") .get("/api/provider/{providerID}", "v2.provider.get")
.at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() })) .at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() }))
.json(404, object, "status"), .json(404, object, "status"),
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, array), http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, locationData(array)),
http.protected http.protected
.get("/api/session/{sessionID}/permission/request", "v2.session.permission.list") .get("/api/session/{sessionID}/permission/request", "v2.session.permission.list")
.seeded((ctx) => ctx.session({ title: "Permission list owner" })) .seeded((ctx) => ctx.session({ title: "Permission list owner" }))
@ -628,7 +644,7 @@ const scenarios: Scenario[] = [
path: route("/api/session/{sessionID}/permission/request", { sessionID: ctx.state.id }), path: route("/api/session/{sessionID}/permission/request", { sessionID: ctx.state.id }),
headers: ctx.headers(), headers: ctx.headers(),
})) }))
.json(200, array), .json(200, data(array)),
http.protected http.protected
.post("/api/session/{sessionID}/permission/request/{requestID}/reply", "v2.session.permission.reply") .post("/api/session/{sessionID}/permission/request/{requestID}/reply", "v2.session.permission.reply")
.seeded((ctx) => ctx.session({ title: "Permission owner" })) .seeded((ctx) => ctx.session({ title: "Permission owner" }))
@ -641,7 +657,7 @@ const scenarios: Scenario[] = [
body: { reply: "once" }, body: { reply: "once" },
})) }))
.json(404, object, "status"), .json(404, object, "status"),
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, array), http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, data(array)),
http.protected http.protected
.delete("/api/permission/saved/{id}", "v2.permission.saved.remove") .delete("/api/permission/saved/{id}", "v2.permission.saved.remove")
.at((ctx) => ({ path: route("/api/permission/saved/{id}", { id: "psv_httpapi_missing" }), headers: ctx.headers() })) .at((ctx) => ({ path: route("/api/permission/saved/{id}", { id: "psv_httpapi_missing" }), headers: ctx.headers() }))
@ -653,8 +669,9 @@ const scenarios: Scenario[] = [
200, 200,
(body) => { (body) => {
object(body) object(body)
array(body.items) object(body.data)
object(body.cursor) array(body.data.items)
object(body.data.cursor)
}, },
"none", "none",
), ),
@ -676,8 +693,9 @@ const scenarios: Scenario[] = [
200, 200,
(body) => { (body) => {
object(body) object(body)
array(body.items) object(body.data)
object(body.cursor) array(body.data.items)
object(body.data.cursor)
}, },
"none", "none",
), ),
@ -698,8 +716,9 @@ const scenarios: Scenario[] = [
200, 200,
(body) => { (body) => {
object(body) object(body)
array(body.items) object(body.data)
object(body.cursor) array(body.data.items)
object(body.data.cursor)
}, },
"none", "none",
), ),

View file

@ -388,8 +388,9 @@ describe("session HttpApi", () => {
yield* insertLegacyAssistantMessage(parent.id) yield* insertLegacyAssistantMessage(parent.id)
expect( expect(
(yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })) (yield* requestJson<{ data: { items: SessionMessage.Message[] } }>(`/api/session/${parent.id}/message`, {
.items, headers,
})).data.items,
).toMatchObject([{ type: "assistant" }]) ).toMatchObject([{ type: "assistant" }])
}), }),
{ git: true, config: { formatter: false, lsp: false } }, { git: true, config: { formatter: false, lsp: false } },
@ -453,7 +454,7 @@ describe("session HttpApi", () => {
})}`, })}`,
{ headers }, { headers },
) )
const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).cursor.next const sessionCursor = (yield* json<{ data: { cursor: { next?: string } } }>(sessionPage)).data.cursor.next
expect(sessionCursor).toBeTruthy() expect(sessionCursor).toBeTruthy()
expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({ expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({
order: "asc", order: "asc",
@ -480,7 +481,7 @@ describe("session HttpApi", () => {
}) })
const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers }) const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
const messageCursor = (yield* json<{ cursor: { next?: string } }>(messagePage)).cursor.next const messageCursor = (yield* json<{ data: { cursor: { next?: string } } }>(messagePage)).data.cursor.next
expect(messageCursor).toBeTruthy() expect(messageCursor).toBeTruthy()
const messageCursorWithOrder = yield* request( const messageCursorWithOrder = yield* request(