feat(codemode): preserve OpenAPI operation namespaces

This commit is contained in:
Aiden Cline
2026-07-03 17:05:17 -05:00
parent 7213242c99
commit e213a08b08
5 changed files with 114 additions and 62 deletions
+35 -10
View File
@@ -7,7 +7,7 @@ import {
isRecord,
methods,
nonEmptyString,
operationName,
operationPath,
operationParameters,
outputSchema,
requestBody,
@@ -15,7 +15,7 @@ import {
securitySchemes,
specServerUrl,
} from "./spec.js"
import type { Operation, Options, Result, Skipped } from "./types.js"
import type { Operation, Options, Result, Skipped, Tools } from "./types.js"
export type {
AuthResolver,
@@ -43,15 +43,17 @@ export const fromSpec = (options: Options): Result => {
const paths = isRecord(document.paths) ? document.paths : {}
const base = options.baseUrl ?? specServerUrl(document)
const used = new Set<string>()
const namespaces = new Set<string>()
const skipped: Array<Skipped> = []
const tools = Object.create(null) as Record<string, Definition<HttpClient.HttpClient>>
const tools = Object.create(null) as Tools
for (const [path, pathValue] of Object.entries(paths)) {
if (!isRecord(pathValue)) continue
for (const [method, operationValue] of Object.entries(pathValue)) {
if (!methods.has(method) || !isRecord(operationValue)) continue
const segments = operationPath(method, path, operationValue, used, namespaces)
const operation: Operation = {
id: operationName(method, path, operationValue, used),
id: segments.join("."),
method: method.toUpperCase(),
path,
summary: nonEmptyString(operationValue.summary),
@@ -82,16 +84,39 @@ export const fromSpec = (options: Options): Result => {
headers: options.headers ?? {},
}
used.add(operation.id)
tools[operation.id] = Tool.make({
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
input: inputSchema(plan.parameters, body, definitions),
output: outputSchema(document, operationValue, definitions),
run: (input) => invoke(plan, input),
})
for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
setTool(
tools,
segments,
Tool.make({
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
input: inputSchema(plan.parameters, body, definitions),
output: outputSchema(document, operationValue, definitions),
run: (input) => invoke(plan, input),
}),
)
}
}
return { tools, skipped }
}
const setTool = (
tools: Tools,
path: ReadonlyArray<string>,
definition: Definition<HttpClient.HttpClient>,
): void => {
const [head, ...rest] = path
if (head === undefined) return
if (rest.length === 0) {
tools[head] = definition
return
}
const child = tools[head]
if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
tools[head] = Object.create(null) as Tools
}
setTool(tools[head] as Tools, rest, definition)
}
export const OpenAPI = { fromSpec }
+31 -11
View File
@@ -206,24 +206,44 @@ export const outputSchema = (
return successes.length > 0 ? { type: "null" } : undefined
}
export const operationName = (
method: string,
path: string,
operation: Record<string, unknown>,
used: ReadonlySet<string>,
): string => {
const raw = nonEmptyString(operation.operationId) ?? `${method}_${path.replaceAll(/[{}]/g, "")}`
const sanitizeOperationSegment = (raw: string): string => {
const base =
raw
.replaceAll(/[^A-Za-z0-9_$]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/^([0-9])/, "_$1") || "operation"
if (!used.has(base) && !blockedOperationNames.has(base)) return base
return blockedOperationNames.has(base) ? `${base}_2` : base
}
export const operationPath = (
method: string,
path: string,
operation: Record<string, unknown>,
used: ReadonlySet<string>,
namespaces: ReadonlySet<string>,
): ReadonlyArray<string> => {
const raw = nonEmptyString(operation.operationId)
const base = (raw === undefined ? [`${method}_${path.replaceAll(/[{}]/g, "")}`] : raw.split("."))
.map(sanitizeOperationSegment)
.filter((segment) => segment !== "")
const segments = base.length === 0 ? ["operation"] : base
if (isOperationPathAvailable(segments, used, namespaces)) return segments
const fallback = [segments.join("_")]
const next = (index: number): string => {
const candidate = `${base}_${index}`
return used.has(candidate) || blockedOperationNames.has(candidate) ? next(index + 1) : candidate
const candidate = `${fallback[0]}_${index}`
return isOperationPathAvailable([candidate], used, namespaces) ? candidate : next(index + 1)
}
return next(2)
return [next(2)]
}
const isOperationPathAvailable = (
segments: ReadonlyArray<string>,
used: ReadonlySet<string>,
namespaces: ReadonlySet<string>,
): boolean => {
const key = segments.join(".")
if (used.has(key) || namespaces.has(key)) return false
return segments.slice(0, -1).every((_, index) => !used.has(segments.slice(0, index + 1).join(".")))
}
export const specServerUrl = (document: Document): string | Skip => {
@@ -68,7 +68,7 @@ export type Skipped = {
readonly reason: string
}
export type Tools = { readonly [name: string]: Definition<HttpClient.HttpClient> }
export type Tools = { [name: string]: Definition<HttpClient.HttpClient> | Tools }
export type Result = {
/** Tool subtree; the host places it under a key in its `tools` tree. */
+15 -19
View File
@@ -20,26 +20,22 @@ export type HostTools<R = never> = {
[name: string]: HostTool<R> | Definition<R> | HostTools<R>
}
export type Services<Tools> = Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
? R
: Tools extends {
readonly _tag: "CodeModeTool"
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
export type Services<Tools> = ServicesOf<Tools, []>
type ServicesOf<Tools, Depth extends ReadonlyArray<unknown>> = Depth["length"] extends 8
? never
: Tools extends (...args: Array<unknown>) => Effect.Effect<unknown, unknown, infer R>
? R
: Tools extends object
? string extends keyof Tools
? // Index-signature records (e.g. adapter-generated tool sets): read the value
// type directly. One level only - the recursive HostTools shape would not
// terminate, and concrete host trees always have literal keys.
Tools[string] extends {
readonly _tag: "CodeModeTool"
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
? R
: never
: Services<Tools[keyof Tools]>
: never
: Tools extends {
readonly _tag: "CodeModeTool"
readonly run: (input: unknown) => Effect.Effect<unknown, unknown, infer R>
}
? R
: Tools extends object
? string extends keyof Tools
? ServicesOf<Tools[string], [...Depth, unknown]>
: ServicesOf<Tools[keyof Tools], [...Depth, unknown]>
: never
/** Minimal audit record retained for each admitted tool call. */
export type ToolCall = {
+32 -21
View File
@@ -33,18 +33,27 @@ const operations = (spec: Document) =>
const nonEmptyString = (value: unknown): string | undefined =>
typeof value === "string" && value !== "" ? value : undefined
const toolNameEntries = (spec: Document) => {
const toolPathEntries = (spec: Document) => {
const used = new Set<string>()
const namespaces = new Set<string>()
return operations(spec).map((item) => {
const { path, method, operation } = item
const raw = nonEmptyString(operation.operationId) ?? `${method}_${path.replaceAll(/[{}]/g, "")}`
const base = raw.replaceAll(/[^A-Za-z0-9_$]+/g, "_").replace(/^_+|_+$/g, "").replace(/^([0-9])/, "_$1") || "operation"
const name = used.has(base) ? `${base}_2` : base
const raw = nonEmptyString(operation.operationId)
const segments = (raw === undefined ? [`${method}_${path.replaceAll(/[{}]/g, "")}`] : raw.split("."))
.map((segment) => segment.replaceAll(/[^A-Za-z0-9_$]+/g, "_").replace(/^_+|_+$/g, "").replace(/^([0-9])/, "_$1") || "operation")
.map((segment) => (["__proto__", "constructor", "prototype"].includes(segment) ? `${segment}_2` : segment))
const key = segments.join(".")
const prefixUsed = segments.slice(0, -1).some((_, index) => used.has(segments.slice(0, index + 1).join(".")))
const name = used.has(key) || namespaces.has(key) || prefixUsed ? `${segments.join("_")}_2` : key
used.add(name)
for (const index of name.split(".").slice(0, -1).keys()) namespaces.add(name.split(".").slice(0, index + 1).join("."))
return { ...item, name }
})
}
const toolAt = (tools: unknown, name: string) =>
name.split(".").reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), tools)
const jsonContentSchema = (content: unknown) =>
isRecord(content)
? Object.entries(content).find(([mediaType]) => {
@@ -82,22 +91,23 @@ describe("OpenAPI.fromSpec", () => {
const spec = await opencodeSpec()
const result = OpenAPI.fromSpec({ spec, baseUrl })
const entries = toolNameEntries(spec)
expect(Object.keys(result.tools).sort()).toStrictEqual(entries.map((entry) => entry.name).sort())
const entries = toolPathEntries(spec)
expect(entries.every((entry) => toolAt(result.tools, entry.name) !== undefined)).toBe(true)
expect(result.skipped).toStrictEqual([])
expect(Object.keys(result.tools)).toContain("global_health")
expect(Object.keys(result.tools)).toContain("file_read")
expect(Object.keys(result.tools)).toContain("session_create")
expect(toolAt(result.tools, "global.health")).not.toBeUndefined()
expect(toolAt(result.tools, "file.read")).not.toBeUndefined()
expect(toolAt(result.tools, "session.create")).not.toBeUndefined()
for (const item of entries) {
const tool = result.tools[item.name]
const tool = toolAt(result.tools, item.name)
expect(tool).toMatchObject({
_tag: "CodeModeTool",
description:
nonEmptyString(item.operation.description) ?? nonEmptyString(item.operation.summary) ?? `${item.method.toUpperCase()} ${item.path}`,
})
const input = isRecord(tool?.input) ? tool.input : {}
const toolRecord = isRecord(tool) ? tool : {}
const input = isRecord(toolRecord.input) ? toolRecord.input : {}
expect(input.type).toBe("object")
const properties = isRecord(input.properties) ? input.properties : {}
const parameters = Array.isArray(item.operation.parameters) ? item.operation.parameters.filter(isRecord) : []
@@ -112,9 +122,8 @@ describe("OpenAPI.fromSpec", () => {
.filter((name): name is string => typeof name === "string")
if (names.length === 0) continue
const groupSchema = isRecord(properties[group.name]) ? properties[group.name] : {}
expect(Object.keys(isRecord(groupSchema.properties) ? groupSchema.properties : {}).sort()).toStrictEqual(
names.sort(),
)
const groupProperties = isRecord(groupSchema) && isRecord(groupSchema.properties) ? groupSchema.properties : {}
expect(Object.keys(groupProperties).sort()).toStrictEqual(names.sort())
}
const requestBody = isRecord(item.operation.requestBody) ? item.operation.requestBody : undefined
@@ -126,7 +135,7 @@ describe("OpenAPI.fromSpec", () => {
.filter(([status]) => /^2\d\d$/.test(status) || status.toUpperCase() === "2XX")
.map(([, response]) => (isRecord(response) ? response : {}))
if (successes.some((response) => jsonContentSchema(response.content) !== undefined)) {
expect(tool?.output).not.toBeUndefined()
expect(toolRecord.output).not.toBeUndefined()
}
}
})
@@ -138,8 +147,10 @@ describe("OpenAPI.fromSpec", () => {
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
expect(result.tools.global_health?.input).toMatchObject({ type: "object", properties: {} })
const input = isRecord(result.tools.global_health?.input) ? result.tools.global_health.input : {}
const health = toolAt(result.tools, "global.health")
const healthInput = isRecord(health) ? health.input : undefined
expect(healthInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(healthInput) ? healthInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
})
@@ -157,7 +168,7 @@ describe("OpenAPI.fromSpec", () => {
expect(result.value).toMatchObject({
items: [
{
path: "tools.opencode.global_health",
path: "tools.opencode.global.health",
description: "Get health information about the OpenCode server.",
},
],
@@ -176,8 +187,8 @@ describe("OpenAPI.fromSpec", () => {
const result = await Effect.runPromise(
runtime
.execute(`
const file = await tools.opencode.file_read({ query: { path: "README.md", directory: "/repo" } })
const session = await tools.opencode.session_create({ body: { title: "hello" } })
const file = await tools.opencode.file.read({ query: { path: "README.md", directory: "/repo" } })
const session = await tools.opencode.session.create({ body: { title: "hello" } })
return { file, session }
`)
.pipe(Effect.provide(layer)),
@@ -201,7 +212,7 @@ describe("OpenAPI.fromSpec", () => {
const runtime = CodeMode.make({ tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools } })
const result = await Effect.runPromise(
runtime.execute("return await tools.opencode.file_read({})").pipe(Effect.provide(layer)),
runtime.execute("return await tools.opencode.file.read({})").pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: false })