Compare commits

..

6 Commits

Author SHA1 Message Date
Kit Langton e84efb181c test(httpapi): smoke test sdk against effect server 2026-04-28 14:28:11 -04:00
Kit Langton 2e8d690ab1 fix(httpapi): finish sdk openapi parity (#24827) 2026-04-28 14:24:10 -04:00
Kit Langton 1ff8d289af fix(tui): handle Zed selection byte offsets (#24825) 2026-04-28 14:09:39 -04:00
Dax d54ffbda1c tui: ignore invalid custom themes to prevent startup crashes (#24645) 2026-04-28 13:58:55 -04:00
Kit Langton c00058ed7a fix(httpapi): align request body openapi shape (#24811) 2026-04-28 12:55:37 -04:00
James Long 2c2fc3499b feat(core): store relative path for sessions (#24704) 2026-04-28 11:51:24 -04:00
18 changed files with 1905 additions and 116 deletions
@@ -0,0 +1 @@
ALTER TABLE `session` ADD `path` text;
File diff suppressed because it is too large Load Diff
@@ -20,6 +20,8 @@ const ZedEditorContentsSchema = z.object({
contents: z.string().nullable(),
})
const utf8 = new TextEncoder()
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
@@ -45,8 +47,8 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
.catch(() => undefined)
if (text == null) return { type: "unavailable" }
const startOffset = Math.min(row.selection_start, row.selection_end)
const endOffset = Math.max(row.selection_start, row.selection_end)
const startOffset = utf8ByteOffsetToStringIndex(text, Math.min(row.selection_start, row.selection_end))
const endOffset = utf8ByteOffsetToStringIndex(text, Math.max(row.selection_start, row.selection_end))
return {
type: "selection",
@@ -158,7 +160,25 @@ function zedWorkspacePaths(value: string | null) {
}
export function offsetToPosition(text: string, offset: number) {
return offsetsToSelection(text, offset, offset).start
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
return offsetsToSelection(text, stringOffset, stringOffset).start
}
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
if (byteOffset <= 0) return 0
let bytes = 0
for (let index = 0; index < text.length; ) {
const codePoint = text.codePointAt(index)
if (codePoint === undefined) return text.length
const nextIndex = index + (codePoint > 0xffff ? 2 : 1)
bytes += utf8.encode(text.slice(index, nextIndex)).length
if (bytes >= byteOffset) return nextIndex
index = nextIndex
}
return text.length
}
function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
@@ -500,7 +500,8 @@ async function getCustomThemes() {
symlink: true,
})) {
const name = path.basename(item, ".json")
result[name] = await Filesystem.readJson(item)
const theme = await Filesystem.readJson(item)
if (isTheme(theme)) result[name] = theme
}
}
return result
+8 -26
View File
@@ -1089,37 +1089,19 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS
}
*/
// Moonshot models want their tools in MFJS format: https://github.com/MoonshotAI/walle/blob/main/docs/mfjs-spec.md
if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) {
const isRecord = (obj: unknown): obj is Record<string, unknown> =>
typeof obj === "object" && obj !== null && !Array.isArray(obj)
const sanitizeMoonshot = (obj: unknown): void => {
if (Array.isArray(obj)) return obj.forEach(sanitizeMoonshot)
if (!isRecord(obj)) return
const sanitizeMoonshot = (obj: unknown): unknown => {
if (obj === null || typeof obj !== "object") return obj
if (Array.isArray(obj)) return obj.map(sanitizeMoonshot)
// Moonshot expands $ref before validation and rejects sibling keywords like description on the same node.
if (typeof obj.$ref === "string") {
for (const key of Object.keys(obj)) {
if (key !== "$ref") delete obj[key]
}
return
}
for (const key of ["title", "$comment", "format"]) {
delete obj[key]
}
for (const key of ["exclusiveMinimum", "exclusiveMaximum", "minContains", "maxContains"]) {
delete obj[key]
}
// MFJS does not support tuple-style arrays (`prefixItems`) or open-ended tuple controls.
const prefixItems = Array.isArray(obj.prefixItems) ? obj.prefixItems : undefined
delete obj.unevaluatedItems
Object.values(obj).forEach(sanitizeMoonshot)
if ("$ref" in obj && typeof obj.$ref === "string") return { $ref: obj.$ref }
const result = Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, sanitizeMoonshot(value)]))
// MFJS does not support tuple-style `items` arrays; it requires one schema object for all array items.
if (Array.isArray(obj.items)) obj.items = obj.items[0] ?? {}
if (prefixItems && !isRecord(obj.items)) obj.items = prefixItems[0] ?? {}
delete obj.prefixItems
if (Array.isArray(result.items)) result.items = result.items[0] ?? {}
return result
}
sanitizeMoonshot(schema)
schema = sanitizeMoonshot(schema) as JSONSchema.BaseSchema | JSONSchema7
}
// Convert integer enums to string enums for Google/Gemini
@@ -21,19 +21,42 @@ type OpenApiParameter = {
name: string
in: string
required?: boolean
schema?: unknown
schema?: OpenApiSchema
}
type OpenApiOperation = {
parameters?: OpenApiParameter[]
responses?: Record<string, unknown>
requestBody?: {
required?: boolean
content?: Record<string, { schema?: OpenApiSchema }>
}
}
type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
type OpenApiSpec = {
components?: {
schemas?: Record<string, OpenApiSchema>
}
paths?: Record<string, OpenApiPathItem>
}
type OpenApiSchema = {
$ref?: string
additionalProperties?: OpenApiSchema | boolean
allOf?: OpenApiSchema[]
anyOf?: OpenApiSchema[]
enum?: string[]
items?: OpenApiSchema
maximum?: number
minimum?: number
oneOf?: OpenApiSchema[]
prefixItems?: OpenApiSchema[]
properties?: Record<string, OpenApiSchema>
type?: string
}
const InstanceQueryParameters = [
{
name: "directory",
@@ -49,24 +72,142 @@ const InstanceQueryParameters = [
},
] satisfies OpenApiParameter[]
function documentInstanceQueryParameters(input: Record<string, unknown>) {
const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"])
const FiniteNumberValues = new Set(["Infinity", "-Infinity", "NaN"])
const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
const QueryBooleanParameters = new Set(["roots", "archived"])
const QueryParameterSchemas = {
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
} satisfies Record<string, OpenApiSchema>
function matchLegacyOpenApi(input: Record<string, unknown>) {
const spec = input as OpenApiSpec
for (const [path, item] of Object.entries(spec.paths ?? {})) {
if (path.startsWith("/global/") || path.startsWith("/auth/")) continue
const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method]
if (!operation) continue
if (operation.requestBody) {
delete operation.requestBody.required
for (const media of Object.values(operation.requestBody.content ?? {})) {
const ref = media.schema?.$ref?.replace("#/components/schemas/", "")
if (ref && LegacyBodyRefParameters.has(ref)) continue
if (ref && spec.components?.schemas?.[ref]) {
media.schema = normalizeRequestSchema(structuredClone(spec.components.schemas[ref]))
continue
}
if (media.schema) media.schema = normalizeRequestSchema(media.schema)
}
if (path === "/experimental/workspace" && method === "post") {
const properties = operation.requestBody.content?.["application/json"]?.schema?.properties
if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] }
if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] }
}
if (path === "/tui/publish" && method === "post" && spec.components?.schemas) {
const schema = operation.requestBody.content?.["application/json"]?.schema
const anyOf = schema?.anyOf
if (anyOf?.length === 4) {
spec.components.schemas.EventTuiPromptAppend = anyOf[0]
spec.components.schemas.EventTuiCommandExecute = anyOf[1]
spec.components.schemas.EventTuiToastShow = anyOf[2]
spec.components.schemas.EventTuiSessionSelect = anyOf[3]
operation.requestBody.content!["application/json"]!.schema = {
anyOf: [
{ $ref: "#/components/schemas/EventTuiPromptAppend" },
{ $ref: "#/components/schemas/EventTuiCommandExecute" },
{ $ref: "#/components/schemas/EventTuiToastShow" },
{ $ref: "#/components/schemas/EventTuiSessionSelect" },
],
}
}
}
if (path === "/sync/replay" && method === "post" && spec.components?.schemas?.SyncReplayEvent) {
const events = operation.requestBody.content?.["application/json"]?.schema?.properties?.events
if (events?.items?.$ref === "#/components/schemas/SyncReplayEvent") {
events.items = normalizeRequestSchema(structuredClone(spec.components.schemas.SyncReplayEvent))
}
}
}
if ((path === "/event" || path === "/global/event") && method === "get") {
operation.responses!["200"] = {
description: "Event stream",
content: {
"text/event-stream": {
schema: path === "/event" ? {} : { $ref: "#/components/schemas/GlobalEvent" },
},
},
}
}
if (!isInstanceRoute) continue
operation.parameters = [
...InstanceQueryParameters,
...(operation.parameters ?? []).filter(
(param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"),
),
]
for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
}
}
return input
}
function normalizeRequestSchema(schema: OpenApiSchema): OpenApiSchema {
const options = flattenOptions(schema.anyOf ?? schema.oneOf)
if (options) {
const withoutNull = options.filter((item) => item.type !== "null")
const finite = withoutNull.find((item) => item.type === "number")
if (finite && withoutNull.every(isFiniteNumberOption)) return { type: "number" }
if (withoutNull.length === 1) return normalizeRequestSchema(withoutNull[0])
if (schema.anyOf) schema.anyOf = withoutNull.map(normalizeRequestSchema)
if (schema.oneOf) schema.oneOf = withoutNull.map(normalizeRequestSchema)
}
if (schema.allOf) {
if (schema.type) delete schema.allOf
else schema.allOf = schema.allOf.map(normalizeRequestSchema)
}
if (schema.prefixItems && schema.items) delete schema.prefixItems
if (schema.items) schema.items = normalizeRequestSchema(schema.items)
if (schema.properties) {
for (const [key, value] of Object.entries(schema.properties)) {
schema.properties[key] = normalizeRequestSchema(value)
}
}
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
schema.additionalProperties = normalizeRequestSchema(schema.additionalProperties)
}
return schema
}
function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined {
return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item])
}
function isFiniteNumberOption(schema: OpenApiSchema) {
if (schema.type === "number") return true
return schema.type === "string" && schema.enum?.every((value) => FiniteNumberValues.has(value)) === true
}
function normalizeParameter(param: OpenApiParameter, route: string) {
if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
if (override) {
param.schema = override
return
}
if (QueryNumberParameters.has(param.name)) {
param.schema = { type: "number" }
return
}
if (QueryBooleanParameters.has(param.name)) {
param.schema = {
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
}
return
}
param.schema = normalizeRequestSchema(param.schema)
}
export const PublicApi = HttpApi.make("opencode")
.addHttpApi(ControlApi)
.addHttpApi(GlobalApi)
@@ -91,6 +232,6 @@ export const PublicApi = HttpApi.make("opencode")
title: "opencode",
version: "1.0.0",
description: "opencode api",
transform: documentInstanceQueryParameters,
transform: matchLegacyOpenApi,
}),
)
@@ -44,6 +44,7 @@ export function toPartialRow(info: DeepPartial<Session.Info>) {
parent_id: grab(info, "parentID"),
slug: grab(info, "slug"),
directory: grab(info, "directory"),
path: grab(info, "path"),
title: grab(info, "title"),
version: grab(info, "version"),
share_url: grab(info, "share", (v) => grab(v, "url")),
@@ -24,6 +24,7 @@ export const SessionTable = sqliteTable(
parent_id: text().$type<SessionID>(),
slug: text().notNull(),
directory: text().notNull(),
path: text(),
title: text().notNull(),
version: text().notNull(),
share_url: text(),
+16 -4
View File
@@ -74,6 +74,7 @@ export function fromRow(row: SessionRow): Info {
projectID: row.project_id,
workspaceID: row.workspace_id ?? undefined,
directory: row.directory,
path: row.path ?? undefined,
parentID: row.parent_id ?? undefined,
title: row.title,
version: row.version,
@@ -98,6 +99,7 @@ export function toRow(info: Info) {
parent_id: info.parentID,
slug: info.slug,
directory: info.directory,
path: info.path,
title: info.title,
version: info.version,
share_url: info.share?.url,
@@ -124,6 +126,10 @@ function getForkedTitle(title: string): string {
return `${title} (fork #1)`
}
function sessionPath(worktree: string, cwd: string) {
return path.relative(path.resolve(worktree), cwd).replaceAll("\\", "/")
}
const Summary = Schema.Struct({
additions: Schema.Number,
deletions: Schema.Number,
@@ -155,6 +161,7 @@ export const Info = Schema.Struct({
projectID: ProjectID,
workspaceID: optionalOmitUndefined(WorkspaceID),
directory: Schema.String,
path: optionalOmitUndefined(Schema.String),
parentID: optionalOmitUndefined(SessionID),
summary: optionalOmitUndefined(Summary),
share: optionalOmitUndefined(Share),
@@ -245,6 +252,7 @@ const UpdatedInfo = Schema.Struct({
projectID: Schema.optional(Schema.NullOr(ProjectID)),
workspaceID: Schema.optional(Schema.NullOr(WorkspaceID)),
directory: Schema.optional(Schema.NullOr(Schema.String)),
path: Schema.optional(Schema.NullOr(Schema.String)),
parentID: Schema.optional(Schema.NullOr(SessionID)),
summary: Schema.optional(Schema.NullOr(Summary)),
share: Schema.optional(UpdatedShare),
@@ -442,6 +450,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
parentID?: SessionID
workspaceID?: WorkspaceID
directory: string
path?: string
permission?: Permission.Ruleset
}) {
const ctx = yield* InstanceState.context
@@ -451,6 +460,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
version: InstallationVersion,
projectID: ctx.project.id,
directory: input.directory,
path: input.path,
workspaceID: input.workspaceID,
parentID: input.parentID,
title: input.title ?? createDefaultTitle(!!input.parentID),
@@ -566,11 +576,12 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
permission?: Permission.Ruleset
workspaceID?: WorkspaceID
}) {
const directory = yield* InstanceState.directory
const ctx = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
return yield* createNext({
parentID: input?.parentID,
directory,
directory: ctx.directory,
path: sessionPath(ctx.worktree, ctx.directory),
title: input?.title,
permission: input?.permission,
workspaceID: workspace,
@@ -578,11 +589,12 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
})
const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
const directory = yield* InstanceState.directory
const ctx = yield* InstanceState.context
const original = yield* get(input.sessionID)
const title = getForkedTitle(original.title)
const session = yield* createNext({
directory,
directory: ctx.directory,
path: sessionPath(ctx.worktree, ctx.directory),
workspaceID: original.workspaceID,
title,
})
@@ -208,6 +208,7 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
parent_id: data.parentID ?? null,
slug: data.slug ?? "",
directory: data.directory ?? "",
path: data.path ?? null,
title: data.title ?? "",
version: data.version ?? "",
share_url: data.share?.url ?? null,
@@ -10,12 +10,14 @@ type ZedFixtureOptions = {
editor?: boolean
selectionStart?: number | null
selectionEnd?: number | null
contents?: string
}
async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) {
const dbPath = path.join(dir, "zed.sqlite")
const filePath = path.join(dir, "file.ts")
await Bun.write(filePath, "one\ntwo\nthree")
const contents = options.contents ?? "one\ntwo\nthree"
await Bun.write(filePath, contents)
const db = new Database(dbPath)
db.run("create table workspaces (workspace_id integer, paths text, timestamp text)")
@@ -27,7 +29,7 @@ async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) {
db.run("insert into panes values (1, 1, 1)")
db.run("insert into items values (1, 1, 1, 1, ?)", [options.itemKind ?? "Editor"])
if (options.editor !== false) {
db.run("insert into editors values (1, 1, ?, ?)", [filePath, "one\ntwo\nthree"])
db.run("insert into editors values (1, 1, ?, ?)", [filePath, contents])
db.run("insert into editor_selections values (1, 1, ?, ?)", [
options.selectionStart === undefined ? 4 : options.selectionStart,
options.selectionEnd === undefined ? 7 : options.selectionEnd,
@@ -38,11 +40,23 @@ async function writeZedFixture(dir: string, options: ZedFixtureOptions = {}) {
return { dbPath, filePath }
}
function utf8ByteOffset(text: string, offset: number) {
return new TextEncoder().encode(text.slice(0, offset)).length
}
test("offsetToPosition converts Zed offsets to 1-based editor positions", () => {
expect(offsetToPosition("one\ntwo\nthree", 0)).toEqual({ line: 1, character: 1 })
expect(offsetToPosition("one\ntwo\nthree", 4)).toEqual({ line: 2, character: 1 })
expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 })
expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 })
expect(offsetToPosition("Ж\nabc", utf8ByteOffset("Ж\nabc", "Ж\nabc".indexOf("a")))).toEqual({
line: 2,
character: 1,
})
expect(offsetToPosition("😀\nabc", utf8ByteOffset("😀\nabc", "😀\nabc".indexOf("a")))).toEqual({
line: 2,
character: 1,
})
})
test("resolveZedSelection returns active editor selection", async () => {
@@ -63,6 +77,102 @@ test("resolveZedSelection returns active editor selection", async () => {
})
})
test("resolveZedSelection converts Zed UTF-8 byte offsets to string offsets", async () => {
await using tmp = await tmpdir()
const contents = "a\nЖЖЖЖЖЖЖЖЖЖ\nb\nTARGET\nz"
const start = contents.indexOf("TARGET")
const fixture = await writeZedFixture(tmp.path, {
contents,
selectionStart: utf8ByteOffset(contents, start),
selectionEnd: utf8ByteOffset(contents, start + "TARGET".length),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
text: "TARGET",
filePath: fixture.filePath,
source: "zed",
selection: {
start: { line: 4, character: 1 },
end: { line: 4, character: 7 },
},
},
})
})
test("resolveZedSelection handles non-ASCII text inside the selected range", async () => {
await using tmp = await tmpdir()
const contents = "a\npre\nвыбор\nz"
const start = contents.indexOf("выбор")
const fixture = await writeZedFixture(tmp.path, {
contents,
selectionStart: utf8ByteOffset(contents, start),
selectionEnd: utf8ByteOffset(contents, start + "выбор".length),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
text: "выбор",
filePath: fixture.filePath,
source: "zed",
selection: {
start: { line: 3, character: 1 },
end: { line: 3, character: 6 },
},
},
})
})
test("resolveZedSelection handles emoji before the selected range", async () => {
await using tmp = await tmpdir()
const contents = "😀\nTARGET\nz"
const start = contents.indexOf("TARGET")
const fixture = await writeZedFixture(tmp.path, {
contents,
selectionStart: utf8ByteOffset(contents, start),
selectionEnd: utf8ByteOffset(contents, start + "TARGET".length),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
text: "TARGET",
filePath: fixture.filePath,
source: "zed",
selection: {
start: { line: 2, character: 1 },
end: { line: 2, character: 7 },
},
},
})
})
test("resolveZedSelection handles reversed Zed byte offsets", async () => {
await using tmp = await tmpdir()
const contents = "a\nЖЖЖ\nTARGET\nz"
const start = contents.indexOf("TARGET")
const fixture = await writeZedFixture(tmp.path, {
contents,
selectionStart: utf8ByteOffset(contents, start + "TARGET".length),
selectionEnd: utf8ByteOffset(contents, start),
})
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({
type: "selection",
selection: {
text: "TARGET",
filePath: fixture.filePath,
source: "zed",
selection: {
start: { line: 3, character: 1 },
end: { line: 3, character: 7 },
},
},
})
})
test("resolveZedSelection returns empty when no workspace matches", async () => {
await using tmp = await tmpdir()
const fixture = await writeZedFixture(tmp.path, {
@@ -997,80 +997,6 @@ describe("ProviderTransform.schema - moonshot $ref siblings", () => {
type: "number",
})
})
test("converts prefixItems tuples to a single item schema", () => {
const result = ProviderTransform.schema(moonshotModel, {
type: "object",
properties: {
renderedSize: {
description: "Rendered size [width, height] in px",
type: "array",
prefixItems: [{ type: "number", title: "Width" }, { type: "number" }],
unevaluatedItems: false,
},
},
} as any) as any
expect(result.properties.renderedSize.prefixItems).toBeUndefined()
expect(result.properties.renderedSize.unevaluatedItems).toBeUndefined()
expect(result.properties.renderedSize.items).toEqual({
type: "number",
})
})
test("removes unsupported annotation fields", () => {
const result = ProviderTransform.schema(moonshotModel, {
title: "Tool input",
$comment: "Internal note",
type: "object",
properties: {
count: {
title: "Count",
$comment: "Generated from int32",
description: "How many items to include.",
default: 10,
format: "int32",
type: "integer",
},
},
} as any) as any
expect(result.title).toBeUndefined()
expect(result.$comment).toBeUndefined()
expect(result.properties.count.title).toBeUndefined()
expect(result.properties.count.$comment).toBeUndefined()
expect(result.properties.count.format).toBeUndefined()
expect(result.properties.count.description).toBe("How many items to include.")
expect(result.properties.count.default).toBe(10)
})
test("removes unsupported complex validation fields", () => {
const result = ProviderTransform.schema(moonshotModel, {
type: "object",
properties: {
count: {
type: "integer",
minimum: 1,
exclusiveMinimum: 0,
exclusiveMaximum: 10,
},
values: {
type: "array",
items: { type: "string" },
contains: { type: "string" },
minContains: 1,
maxContains: 3,
},
},
} as any) as any
expect(result.properties.count.exclusiveMinimum).toBeUndefined()
expect(result.properties.count.exclusiveMaximum).toBeUndefined()
expect(result.properties.count.minimum).toBe(1)
expect(result.properties.values.minContains).toBeUndefined()
expect(result.properties.values.maxContains).toBeUndefined()
expect(result.properties.values.contains).toEqual({ type: "string" })
})
})
describe("ProviderTransform.message - DeepSeek reasoning content", () => {
@@ -18,6 +18,11 @@ const original = {
}
const methods = ["get", "post", "put", "delete", "patch"] as const
let effectSpec: ReturnType<typeof OpenApi.fromApi> | undefined
function effectOpenApi() {
return (effectSpec ??= OpenApi.fromApi(PublicApi))
}
function app(input?: { password?: string; username?: string }) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
@@ -50,8 +55,25 @@ function openApiParameters(spec: { paths: Record<string, Partial<Record<(typeof
)
}
function openApiRequestBodies(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }) {
return Object.fromEntries(
Object.entries(spec.paths).flatMap(([path, item]) =>
methods
.filter((method) => item[method])
.map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(item[method]?.requestBody)]),
),
)
}
type Operation = {
parameters?: unknown[]
responses?: unknown
requestBody?: unknown
}
type RequestBody = {
content?: Record<string, { schema?: { $ref?: string; type?: string } }>
required?: boolean
}
function parameterKey(param: unknown) {
@@ -60,6 +82,47 @@ function parameterKey(param: unknown) {
return `${param.in}:${param.name}:${"required" in param && param.required === true}`
}
function parameterSchema(input: {
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
path: string
method: (typeof methods)[number]
name: string
}) {
const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find(
(param) => !!param && typeof param === "object" && "name" in param && param.name === input.name,
)
if (!param || typeof param !== "object" || !("schema" in param)) return
return param.schema
}
function requestBodyKey(body: unknown) {
if (!body || typeof body !== "object" || !("content" in body)) return ""
const requestBody = body as RequestBody
return JSON.stringify({
required: requestBody.required === true,
content: Object.entries(requestBody.content ?? {})
.map(([type, value]) => [type, value.schema?.$ref ?? value.schema?.type ?? "inline"])
.sort(),
})
}
function responseContentTypes(input: {
spec: { paths: Record<string, Partial<Record<(typeof methods)[number], Operation>>> }
path: string
method: (typeof methods)[number]
status: string
}) {
const responses = input.spec.paths[input.path]?.[input.method]?.responses
if (!responses || typeof responses !== "object" || !(input.status in responses)) return []
const response = (responses as Record<string, unknown>)[input.status]
if (!response || typeof response !== "object" || !("content" in response)) return []
const content = (response as { content?: unknown }).content
if (!content || typeof content !== "object") {
return []
}
return Object.keys(content).sort()
}
function authorization(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
@@ -83,7 +146,7 @@ afterEach(async () => {
describe("HttpApi server", () => {
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
const honoRoutes = openApiRouteKeys(await Server.openapi())
const effectRoutes = openApiRouteKeys(OpenApi.fromApi(PublicApi))
const effectRoutes = openApiRouteKeys(effectOpenApi())
expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([])
@@ -91,7 +154,7 @@ describe("HttpApi server", () => {
test("matches generated OpenAPI route parameters", async () => {
const hono = openApiParameters(await Server.openapi())
const effect = openApiParameters(OpenApi.fromApi(PublicApi))
const effect = openApiParameters(effectOpenApi())
expect(
Object.keys(hono)
@@ -100,6 +163,49 @@ describe("HttpApi server", () => {
).toEqual([])
})
test("matches generated OpenAPI request body shape", async () => {
const hono = openApiRequestBodies(await Server.openapi())
const effect = openApiRequestBodies(effectOpenApi())
expect(
Object.keys(hono)
.filter((route) => hono[route] !== effect[route])
.map((route) => ({ route, hono: hono[route], effect: effect[route] })),
).toEqual([])
})
test("matches SDK-affecting query parameter schemas", async () => {
const effect = effectOpenApi()
expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "roots" })).toEqual({
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
})
expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "start" })).toEqual({
type: "number",
})
expect(parameterSchema({ spec: effect, path: "/find/file", method: "get", name: "limit" })).toEqual({
type: "integer",
minimum: 1,
maximum: 200,
})
expect(parameterSchema({ spec: effect, path: "/session/{sessionID}/message", method: "get", name: "limit" })).toEqual({
type: "integer",
minimum: 0,
maximum: Number.MAX_SAFE_INTEGER,
})
})
test("documents event routes as server-sent events", () => {
const effect = effectOpenApi()
expect(responseContentTypes({ spec: effect, path: "/event", method: "get", status: "200" })).toEqual([
"text/event-stream",
])
expect(responseContentTypes({ spec: effect, path: "/global/event", method: "get", status: "200" })).toEqual([
"text/event-stream",
])
})
test("allows requests when auth is disabled", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(`${tmp.path}/hello.txt`, "hello")
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Instance } from "../../src/project/instance"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
function sdk(directory: string) {
const handler = ExperimentalHttpApiServer.webHandler().handler
return createOpencodeClient({
baseUrl: "http://opencode.test",
directory,
fetch: ((input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
return handler(request, ExperimentalHttpApiServer.context)
}) as typeof fetch,
})
}
afterEach(async () => {
await Instance.disposeAll()
await resetDatabase()
})
describe("HttpApi SDK", () => {
test("serves generated SDK requests through the experimental Effect server", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
await Bun.write(`${tmp.path}/hello.txt`, "hello")
const client = sdk(tmp.path)
const file = await client.file.read({ path: "hello.txt" })
expect(file.response.status).toBe(200)
expect(file.data?.content).toBe("hello")
const created = await client.session.create({ title: "sdk session" })
if (!created.data) throw new Error("Expected session create response data")
expect(created.response.status).toBe(200)
expect(created.data.title).toBe("sdk session")
const listed = await client.session.list({ roots: true, limit: 10 })
expect(listed.response.status).toBe(200)
expect(listed.data?.map((item) => item.id)).toContain(created.data.id)
})
})
@@ -59,6 +59,7 @@ describe("Session.Info", () => {
projectID,
workspaceID,
directory: "/tmp/proj",
path: "packages/opencode",
parentID: sessionIDChild,
summary: {
additions: 10,
@@ -54,6 +54,7 @@ describe("session.created event", () => {
expect(receivedInfo?.id).toBe(info.id)
expect(receivedInfo?.projectID).toBe(info.projectID)
expect(receivedInfo?.directory).toBe(info.directory)
expect(receivedInfo?.path).toBe(info.path)
expect(receivedInfo?.title).toBe(info.title)
await remove(info.id)
+3
View File
@@ -936,6 +936,7 @@ export type Session = {
projectID: string
workspaceID?: string
directory: string
path?: string
parentID?: string
summary?: {
additions: number
@@ -1063,6 +1064,7 @@ export type SyncEventSessionUpdated = {
projectID?: string | null
workspaceID?: string | null
directory?: string | null
path?: string | null
parentID?: string | null
summary?: {
additions: number
@@ -1882,6 +1884,7 @@ export type GlobalSession = {
projectID: string
workspaceID?: string
directory: string
path?: string
parentID?: string
summary?: {
additions: number
+16
View File
@@ -10154,6 +10154,9 @@
"directory": {
"type": "string"
},
"path": {
"type": "string"
},
"parentID": {
"type": "string",
"pattern": "^ses.*"
@@ -10584,6 +10587,16 @@
}
]
},
"path": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"parentID": {
"anyOf": [
{
@@ -12538,6 +12551,9 @@
"directory": {
"type": "string"
},
"path": {
"type": "string"
},
"parentID": {
"type": "string",
"pattern": "^ses.*"