fix(codemode): resolve OpenAPI tool conflicts

This commit is contained in:
Aiden Cline
2026-07-03 23:46:57 -05:00
parent 2316f5e5b3
commit 6dd9a8158a
5 changed files with 91 additions and 32 deletions
@@ -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) {
+26 -1
View File
@@ -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",
+56 -26
View File
@@ -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({
+1
View File
@@ -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) => ({