fix(codemode): resolve OpenAPI tool conflicts
This commit is contained in:
@@ -59,6 +59,12 @@ export const fromSpec = (options: Options): Result => {
|
||||
description: nonEmptyString(operationValue.description),
|
||||
}
|
||||
if (options.operations !== undefined && !options.operations(operation)) continue
|
||||
// TODO: Represent bidirectional streams as an explicit host capability before
|
||||
// exposing WebSocket operations as callable CodeMode tools.
|
||||
if (operationValue["x-websocket"] === true) {
|
||||
skipped.push({ method: operation.method, path, reason: "WebSocket operations are not supported" })
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof base !== "string") {
|
||||
skipped.push({ method: operation.method, path, reason: base.reason })
|
||||
@@ -100,11 +106,7 @@ export const fromSpec = (options: Options): Result => {
|
||||
return { tools, skipped }
|
||||
}
|
||||
|
||||
const setTool = (
|
||||
tools: Tools,
|
||||
path: ReadonlyArray<string>,
|
||||
definition: Definition<HttpClient.HttpClient>,
|
||||
): void => {
|
||||
const setTool = (tools: Tools, path: ReadonlyArray<string>, definition: Definition<HttpClient.HttpClient>): void => {
|
||||
const [head, ...rest] = path
|
||||
if (head === undefined) return
|
||||
if (rest.length === 0) {
|
||||
|
||||
@@ -16,6 +16,18 @@ const parameterLocationSet = new Set<string>(parameterLocations)
|
||||
const ignoredHeaderParameters = new Set(["accept", "content-type", "authorization"])
|
||||
const schemeTypes = new Set(["apiKey", "http", "oauth2", "openIdConnect"])
|
||||
const blockedOperationNames = new Set(["__proto__", "constructor", "prototype"])
|
||||
const schemaShapeKeys = new Set([
|
||||
"$ref",
|
||||
"type",
|
||||
"enum",
|
||||
"const",
|
||||
"anyOf",
|
||||
"oneOf",
|
||||
"allOf",
|
||||
"properties",
|
||||
"items",
|
||||
"additionalProperties",
|
||||
])
|
||||
export const maxErrorBodyChars = 1_024
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -60,7 +72,9 @@ const projectSchema = (value: unknown, depth = 0): JsonSchema => {
|
||||
const description = nonEmptyString(value.description)
|
||||
const format = nonEmptyString(value.format)
|
||||
const allOf = Array.isArray(value.allOf)
|
||||
? value.allOf.map((item) => projectSchema(item, depth + 1)).filter((item) => Object.keys(item).length > 0)
|
||||
? value.allOf
|
||||
.map((item) => projectSchema(item, depth + 1))
|
||||
.filter((item) => Object.keys(item).some((key) => schemaShapeKeys.has(key)))
|
||||
: []
|
||||
const projected: JsonSchema = {
|
||||
...(type === undefined ? {} : { type }),
|
||||
@@ -273,6 +287,17 @@ export const operationPath = (
|
||||
.filter((segment) => segment !== "")
|
||||
const segments = base.length === 0 ? ["operation"] : base
|
||||
if (isOperationPathAvailable(segments, used, namespaces)) return segments
|
||||
const conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join(".")))
|
||||
if (conflict >= 0 && conflict + 1 < segments.length) {
|
||||
const collapsed = segments.flatMap((segment, index) => {
|
||||
if (index === conflict) {
|
||||
const next = segments[index + 1] ?? ""
|
||||
return [`${segment}${next.charAt(0).toUpperCase()}${next.slice(1)}`]
|
||||
}
|
||||
return index === conflict + 1 ? [] : [segment]
|
||||
})
|
||||
if (isOperationPathAvailable(collapsed, used, namespaces)) return collapsed
|
||||
}
|
||||
const fallback = [segments.join("_")]
|
||||
const next = (index: number): string => {
|
||||
const candidate = `${fallback[0]}_${index}`
|
||||
|
||||
@@ -8005,6 +8005,7 @@
|
||||
"pty"
|
||||
],
|
||||
"operationId": "v2.pty.connect",
|
||||
"x-websocket": true,
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ptyID",
|
||||
|
||||
@@ -37,31 +37,50 @@ const nonEmptyString = (value: unknown): string | undefined =>
|
||||
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)
|
||||
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 }
|
||||
})
|
||||
return operations(spec)
|
||||
.filter((item) => item.operation["x-websocket"] !== true)
|
||||
.map((item) => {
|
||||
const { path, method, operation } = item
|
||||
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 conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join(".")))
|
||||
const collapsed =
|
||||
conflict >= 0 && conflict + 1 < segments.length
|
||||
? segments.flatMap((segment, index) => {
|
||||
if (index === conflict) {
|
||||
const next = segments[index + 1] ?? ""
|
||||
return [`${segment}${next.charAt(0).toUpperCase()}${next.slice(1)}`]
|
||||
}
|
||||
return index === conflict + 1 ? [] : [segment]
|
||||
})
|
||||
: undefined
|
||||
const collapsedKey = collapsed?.join(".")
|
||||
const name =
|
||||
collapsedKey !== undefined && !used.has(collapsedKey) && !namespaces.has(collapsedKey)
|
||||
? collapsedKey
|
||||
: 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) =>
|
||||
@@ -106,7 +125,11 @@ describe("OpenAPI.fromSpec", () => {
|
||||
|
||||
const entries = toolPathEntries(spec)
|
||||
expect(entries.every((entry) => toolAt(result.tools, entry.name) !== undefined)).toBe(true)
|
||||
expect(result.skipped).toStrictEqual([])
|
||||
expect(result.skipped).toContainEqual({
|
||||
method: "GET",
|
||||
path: "/api/pty/{ptyID}/connect",
|
||||
reason: "WebSocket operations are not supported",
|
||||
})
|
||||
expect(toolAt(result.tools, "v2.health.get")).not.toBeUndefined()
|
||||
expect(toolAt(result.tools, "v2.session.get")).not.toBeUndefined()
|
||||
expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined()
|
||||
@@ -123,6 +146,13 @@ describe("OpenAPI.fromSpec", () => {
|
||||
if (!Tool.isDefinition(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
|
||||
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
|
||||
|
||||
const contextEntryPut = toolAt(result.tools, "v2.session.contextEntry.put")
|
||||
expect(Tool.isDefinition(contextEntryPut)).toBe(true)
|
||||
if (!Tool.isDefinition(contextEntryPut)) throw new Error("v2.session.contextEntry.put was not generated")
|
||||
expect(inputTypeScript(contextEntryPut)).toBe("{ sessionID: string; key: string; value: unknown }")
|
||||
expect(toolAt(result.tools, "v2_session_context_entry_put_2")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined()
|
||||
|
||||
for (const item of entries) {
|
||||
const tool = toolAt(result.tools, item.name)
|
||||
expect(tool).toMatchObject({
|
||||
|
||||
@@ -127,6 +127,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
|
||||
description: "Establish a WebSocket connection streaming PTY output and accepting terminal input.",
|
||||
transform: (operation) => ({
|
||||
...operation,
|
||||
"x-websocket": true,
|
||||
parameters: [
|
||||
...(operation.parameters ?? []),
|
||||
...["location[directory]", "location[workspace]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
|
||||
|
||||
Reference in New Issue
Block a user