diff --git a/.changeset/openapi-vendor-json-content-type.md b/.changeset/openapi-vendor-json-content-type.md new file mode 100644 index 0000000000..24c4da544e --- /dev/null +++ b/.changeset/openapi-vendor-json-content-type.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-openapi": patch +--- + +Preserve vendor +json Content-Type on OpenAPI object request bodies. diff --git a/.changeset/toolkits-empty-grid.md b/.changeset/toolkits-empty-grid.md new file mode 100644 index 0000000000..fa868f391f --- /dev/null +++ b/.changeset/toolkits-empty-grid.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Prevent the empty Toolkits page from scrolling past its visible add cards. diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 71ad6c4f41..2184b4ec12 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -197,7 +197,10 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo > - Read docs + Read docs diff --git a/e2e/scenarios/connection-remove-confirm.test.ts b/e2e/scenarios/connection-remove-confirm.test.ts index b0c8ab8146..736d8740ca 100644 --- a/e2e/scenarios/connection-remove-confirm.test.ts +++ b/e2e/scenarios/connection-remove-confirm.test.ts @@ -41,7 +41,7 @@ scenario( const client = yield* makeClient(api, identity); const slug = IntegrationSlug.make(`rm-confirm-${randomBytes(4).toString("hex")}`); - const name = ConnectionName.make("main"); + const name = ConnectionName.make("longconnectionnamethatmustwrapwithoutoverflow"); yield* Effect.ensuring( Effect.gen(function* () { @@ -73,9 +73,10 @@ scenario( const connections = page.locator("section").filter({ has: page.getByRole("heading", { level: 3, name: "Connections" }), }); - const row = connections.getByText("main", { exact: true }); + const row = connections.getByText(String(name), { exact: true }); const menuTrigger = connections.locator('button[aria-haspopup="menu"]'); const confirm = page.getByRole("alertdialog"); + const removeAction = confirm.getByRole("button", { name: "Remove connection" }); await step("Open the integration's connections", async () => { await visit(page, `/integrations/${slug}`); @@ -85,7 +86,24 @@ scenario( await step("Remove asks for confirmation instead of firing", async () => { await menuTrigger.click(); await page.getByRole("menuitem", { name: "Remove" }).click(); - await confirm.getByText("Remove main?").waitFor(); + const title = confirm.getByText(`Remove ${String(name)}?`); + await title.waitFor(); + await removeAction.getByText("Remove", { exact: true }).waitFor(); + + const layout = await confirm.evaluate((dialog) => { + const title = dialog.querySelector('[data-slot="alert-dialog-title"]'); + if (title === null) return null; + const titleText = document.createRange(); + titleText.selectNodeContents(title); + return { + dialogFits: dialog.scrollWidth <= dialog.clientWidth + 1, + titleWraps: titleText.getClientRects().length > 1, + }; + }); + expect(layout, "the confirmation title wraps without widening the dialog").toEqual({ + dialogFits: true, + titleWraps: true, + }); }); await step("Cancel keeps the connection", async () => { @@ -97,7 +115,7 @@ scenario( await step("Confirming actually removes it", async () => { await menuTrigger.click(); await page.getByRole("menuitem", { name: "Remove" }).click(); - await confirm.getByRole("button", { name: "Remove connection" }).click(); + await removeAction.click(); await confirm.waitFor({ state: "detached" }); await row.waitFor({ state: "detached" }); }); diff --git a/e2e/selfhost/toolkits-empty-grid.test.ts b/e2e/selfhost/toolkits-empty-grid.test.ts new file mode 100644 index 0000000000..41fe93091b --- /dev/null +++ b/e2e/selfhost/toolkits-empty-grid.test.ts @@ -0,0 +1,56 @@ +// An empty Toolkits grid must fit the viewport: with no toolkits in either +// scope, both the Workspace and Personal add cards sit above the fold and the +// grid does not scroll. Regression: each shelf reserved a fixed ~3-row +// min-height, so two empty shelves stacked past the viewport and the page +// scrolled with nothing to see. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +scenario( + "Toolkits · empty grid fits the viewport without scrolling", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the Toolkits page with no toolkits in either scope", async () => { + await visit(page, "/default/toolkits/"); + await page.getByRole("heading", { name: "Toolkits", level: 1 }).waitFor(); + await page.getByRole("heading", { name: "Workspace" }).waitFor(); + await page.getByRole("heading", { name: "Personal" }).waitFor(); + await page.getByRole("button", { name: "Add workspace toolkit" }).waitFor(); + await page.getByRole("button", { name: "Add personal toolkit" }).waitFor(); + await page.locator('main [data-slot="skeleton"]').first().waitFor({ state: "detached" }); + }); + + await step("The empty grid does not overflow its scroll container", async () => { + // Walk up from the Personal add card to its nearest scrollable + // ancestor; on an empty grid that ancestor must have nothing to + // scroll. This is the user-visible contract — both add cards are + // reachable without scrolling — measured at the scroll boundary. + const overflow = await page.evaluate(() => { + const addCard = [...document.querySelectorAll("button")].find( + (button) => button.getAttribute("aria-label") === "Add personal toolkit", + ); + let node: HTMLElement | null = addCard ?? null; + while (node && node.scrollHeight <= node.clientHeight + 1) { + node = node.parentElement; + } + if (!node) return null; + return { + tag: node.tagName, + scrollHeight: node.scrollHeight, + clientHeight: node.clientHeight, + }; + }); + expect(overflow, "no scrollable ancestor overflows for an empty grid").toBeNull(); + }); + }); + }), +); diff --git a/packages/core/execution/src/skills.test.ts b/packages/core/execution/src/skills.test.ts index 97e76e1062..846acb6860 100644 --- a/packages/core/execution/src/skills.test.ts +++ b/packages/core/execution/src/skills.test.ts @@ -13,6 +13,7 @@ describe("skills registry", () => { expect(EXECUTE_SKILL.body).toContain( "Do not use `fetch` — all API calls go through `tools.*`.", ); + expect(EXECUTE_SKILL.body).toContain("read `result.data.connections`"); }); it("finds a skill by exact name and misses unknown names", () => { diff --git a/packages/core/execution/src/skills.ts b/packages/core/execution/src/skills.ts index deeb31cb3b..b4803449ae 100644 --- a/packages/core/execution/src/skills.ts +++ b/packages/core/execution/src/skills.ts @@ -36,7 +36,7 @@ const EXECUTE_SKILL_BODY = [ '2. `const path = matches[0]?.path; if (!path) return "No matching tools found.";`', "3. `const details = await tools.describe.tool({ path });`", "4. Use `details.inputTypeScript` / `details.outputTypeScript` and `details.typeScriptDefinitions` for compact shapes.", - "5. Use `tools.executor.coreTools.connections.list({})` when you need live saved-connection inventory.", + "5. For live saved-connection inventory, call `tools.executor.coreTools.connections.list({})`; after checking `result.ok`, read `result.data.connections`.", "6. Call the tool: `const result = await tools.(input);`", "", "## Rules", diff --git a/packages/core/sdk/README.md b/packages/core/sdk/README.md index 658054ad13..ac616178cc 100644 --- a/packages/core/sdk/README.md +++ b/packages/core/sdk/README.md @@ -42,7 +42,7 @@ const executor = await createExecutor({ onElicitation: "accept-all" }); const tools = await executor.tools.list(); const target = tools[0]; if (target) { - const result = await executor.tools.invoke(target.id, { + const result = await executor.tools.invoke(target.address, { /* args matching target.inputSchema */ }); console.log(result); @@ -54,7 +54,7 @@ await executor.close(); Pass an `options` object only if you need to override the executor-level handler for a single call (rare — typically used by hosts that bridge per-client elicitation channels): ```ts -await executor.tools.invoke(target.id, args, { +await executor.tools.invoke(target.address, args, { onElicitation: customHandler, }); ``` diff --git a/packages/plugins/openapi/src/sdk/invoke.ts b/packages/plugins/openapi/src/sdk/invoke.ts index 148b4a052a..2f9bd0f6f5 100644 --- a/packages/plugins/openapi/src/sdk/invoke.ts +++ b/packages/plugins/openapi/src/sdk/invoke.ts @@ -838,12 +838,8 @@ const applyRequestBody = ( }); if (isJsonContentType(contentType)) { - // Pre-serialized JSON strings pass through with the declared media - // type preserved (important for `application/vnd.foo+json` etc.). - if (typeof bodyValue === "string") { - return sent(HttpClientRequest.bodyText(request, bodyValue, contentType)); - } - return sent(HttpClientRequest.bodyJsonUnsafe(request, bodyValue)); + const text = typeof bodyValue === "string" ? bodyValue : JSON.stringify(bodyValue); + return sent(HttpClientRequest.bodyText(request, text, contentType)); } if (isFormUrlEncoded(contentType)) { diff --git a/packages/plugins/openapi/src/sdk/non-json-body.test.ts b/packages/plugins/openapi/src/sdk/non-json-body.test.ts index 87deeaba98..d8619e13fe 100644 --- a/packages/plugins/openapi/src/sdk/non-json-body.test.ts +++ b/packages/plugins/openapi/src/sdk/non-json-body.test.ts @@ -447,6 +447,36 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); + it.effect("application/vnd.api+json: object body keeps the declared content type", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer({ + name: "createNote", + path: "/notes", + payload: JsonNameObject, + transformSpec: replaceRequestBodyContent("/notes", "post", { + "application/vnd.api+json": { + schema: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "notes-jsonapi" }); + + yield* executor.execute(conn.address("body.createNote"), { + body: { name: "Acme" }, + }); + + expect(captured.contentType).toBe("application/vnd.api+json"); + expect(decodeJsonNameBody(captured.body.toString("utf8"))).toEqual({ name: "Acme" }); + }), + ); + it.effect("multipart/form-data: only encoder-supported file shapes are advertised", () => Effect.gen(function* () { const { server } = yield* startEchoServer({ diff --git a/packages/plugins/toolkits/src/page.tsx b/packages/plugins/toolkits/src/page.tsx index 9e78eaddbb..5d1a9f2a3e 100644 --- a/packages/plugins/toolkits/src/page.tsx +++ b/packages/plugins/toolkits/src/page.tsx @@ -181,7 +181,6 @@ const toolkitByRouteSlug = ( }; const toolkitCardStyle = { minHeight: "9rem" }; -const toolkitShelfStyle = { minHeight: "28.5rem" }; const toolkitGridContainerStyle = { maxWidth: "80rem" }; const toolkitToolTreeStyle = { width: "24rem" }; @@ -636,10 +635,7 @@ function ToolkitSection(props: { ) : null} -
+
{rows.map((toolkit) => ( ))} @@ -1204,10 +1200,7 @@ function ToolkitSectionSkeleton(props: { title?: string }) {
) : null} -
+
diff --git a/packages/react/src/components/accounts-section.tsx b/packages/react/src/components/accounts-section.tsx index d9189a796f..a87332e654 100644 --- a/packages/react/src/components/accounts-section.tsx +++ b/packages/react/src/components/accounts-section.tsx @@ -571,13 +571,14 @@ function OwnerAccounts(props: { Cancel { if (props.canManageConnections && removingConnection !== null) { void handleRemove(removingConnection); } }} > - Remove connection + Remove diff --git a/packages/react/src/components/alert-dialog.tsx b/packages/react/src/components/alert-dialog.tsx index e26dbfe1d5..8e76c355fa 100644 --- a/packages/react/src/components/alert-dialog.tsx +++ b/packages/react/src/components/alert-dialog.tsx @@ -93,7 +93,7 @@ function AlertDialogTitle({