mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 09:16:20 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e84efb181c | |||
| 2e8d690ab1 | |||
| 1ff8d289af | |||
| d54ffbda1c | |||
| c00058ed7a | |||
| 2c2fc3499b |
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*"
|
||||
|
||||
Reference in New Issue
Block a user