From 6dd9a8158aeef352d01f6dd3efd36be223e63591 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 3 Jul 2026 23:46:57 -0500 Subject: [PATCH] fix(codemode): resolve OpenAPI tool conflicts --- .../codemode/src/adapters/openapi/index.ts | 12 +-- .../codemode/src/adapters/openapi/spec.ts | 27 +++++- .../test/fixtures/opencode-v2-openapi.json | 1 + packages/codemode/test/openapi.test.ts | 82 +++++++++++++------ packages/protocol/src/groups/pty.ts | 1 + 5 files changed, 91 insertions(+), 32 deletions(-) diff --git a/packages/codemode/src/adapters/openapi/index.ts b/packages/codemode/src/adapters/openapi/index.ts index bc379303b6..21cc774907 100644 --- a/packages/codemode/src/adapters/openapi/index.ts +++ b/packages/codemode/src/adapters/openapi/index.ts @@ -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, - definition: Definition, -): void => { +const setTool = (tools: Tools, path: ReadonlyArray, definition: Definition): void => { const [head, ...rest] = path if (head === undefined) return if (rest.length === 0) { diff --git a/packages/codemode/src/adapters/openapi/spec.ts b/packages/codemode/src/adapters/openapi/spec.ts index 77983e68d7..f5138244fc 100644 --- a/packages/codemode/src/adapters/openapi/spec.ts +++ b/packages/codemode/src/adapters/openapi/spec.ts @@ -16,6 +16,18 @@ const parameterLocationSet = new Set(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 => @@ -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}` diff --git a/packages/codemode/test/fixtures/opencode-v2-openapi.json b/packages/codemode/test/fixtures/opencode-v2-openapi.json index ae1a3accce..4d1d3af708 100644 --- a/packages/codemode/test/fixtures/opencode-v2-openapi.json +++ b/packages/codemode/test/fixtures/opencode-v2-openapi.json @@ -8005,6 +8005,7 @@ "pty" ], "operationId": "v2.pty.connect", + "x-websocket": true, "parameters": [ { "name": "ptyID", diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index bcfabdf2f8..71e4078946 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -37,31 +37,50 @@ const nonEmptyString = (value: unknown): string | undefined => const toolPathEntries = (spec: Document) => { const used = new Set() const namespaces = new Set() - 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({ diff --git a/packages/protocol/src/groups/pty.ts b/packages/protocol/src/groups/pty.ts index a6ec1b66b6..a40b6c4b53 100644 --- a/packages/protocol/src/groups/pty.ts +++ b/packages/protocol/src/groups/pty.ts @@ -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) => ({