Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/openapi-vendor-json-content-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/plugin-openapi": patch
---

Preserve vendor +json Content-Type on OpenAPI object request bodies.
5 changes: 5 additions & 0 deletions .changeset/toolkits-empty-grid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

Prevent the empty Toolkits page from scrolling past its visible add cards.
5 changes: 4 additions & 1 deletion apps/marketing/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,10 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo
>
</span>
</button>
<a href="https://executor.sh/docs" class="btn-secondary">Read docs</a>
<a
href="https://executor.sh/docs#set-up-with-your-agent"
class="btn-secondary">Read docs</a
>
</div>
</div>

Expand Down
26 changes: 22 additions & 4 deletions e2e/scenarios/connection-remove-confirm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down Expand Up @@ -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}`);
Expand All @@ -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<HTMLElement>('[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 () => {
Expand All @@ -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" });
});
Expand Down
56 changes: 56 additions & 0 deletions e2e/selfhost/toolkits-empty-grid.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
}),
);
1 change: 1 addition & 0 deletions packages/core/execution/src/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/execution/src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<path>(input);`",
"",
"## Rules",
Expand Down
4 changes: 2 additions & 2 deletions packages/core/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
});
```
Expand Down
8 changes: 2 additions & 6 deletions packages/plugins/openapi/src/sdk/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
30 changes: 30 additions & 0 deletions packages/plugins/openapi/src/sdk/non-json-body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
11 changes: 2 additions & 9 deletions packages/plugins/toolkits/src/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,6 @@ const toolkitByRouteSlug = (
};

const toolkitCardStyle = { minHeight: "9rem" };
const toolkitShelfStyle = { minHeight: "28.5rem" };
const toolkitGridContainerStyle = { maxWidth: "80rem" };
const toolkitToolTreeStyle = { width: "24rem" };

Expand Down Expand Up @@ -636,10 +635,7 @@ function ToolkitSection(props: {
</div>
) : null}

<div
className="grid content-start grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3"
style={toolkitShelfStyle}
>
<div className="grid content-start grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
{rows.map((toolkit) => (
<ToolkitTile key={toolkit.id} showOwnerLabels={props.showOwnerLabels} toolkit={toolkit} />
))}
Expand Down Expand Up @@ -1204,10 +1200,7 @@ function ToolkitSectionSkeleton(props: { title?: string }) {
</div>
) : null}

<div
className="grid content-start grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3"
style={toolkitShelfStyle}
>
<div className="grid content-start grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
<ToolkitTileSkeleton />
</div>
</section>
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/components/accounts-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -571,13 +571,14 @@ function OwnerAccounts(props: {
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
aria-label="Remove connection"
onClick={() => {
if (props.canManageConnections && removingConnection !== null) {
void handleRemove(removingConnection);
}
}}
>
Remove connection
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/components/alert-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ function AlertDialogTitle({
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
"wrap-anywhere text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className,
)}
{...props}
Expand Down
Loading