History

Enhance executor functionality and improve diagnostics

- Integrated health checks for managed backend and web services, ensuring proper status reporting. - Updated the `runDoctor` function to provide detailed feedback on backend readiness and function bootstrap status. - Added a new script function to wait for executor services to be fully ready during installation. - Refactored type hinting logic to improve clarity and maintainability in OpenAPI tool generation. - Removed obsolete end-to-end test file for anonymous MCP endpoint.

Rhys Sullivan committed 9 months ago

Commit 2a77b621 parent a96804a15 files · +591 −122Browse files

Unified · Split

15 files
  1. executor/apps/web/src/components/tools/explorer-tool-detail.tsx +2 −2
  2. executor/executor.ts +45 −1
  3. executor/install +34 −0
  4. executor/packages/convex/auth.config.ts +2 −4
  5. executor/packages/convex/http-anonymous-mcp.e2e.test.ts +0 −90
  6. executor/packages/convex/runtime/tool_source_loading.ts +1 −1
  7. executor/packages/core/src/managed-runtime-bootstrap.ts +68 −9
  8. executor/packages/core/src/mcp-server-formatting.ts +1 −1
  9. executor/packages/core/src/openapi-cache.test.ts +48 −2
  10. executor/packages/core/src/openapi-real-specs.test.ts +179 −0
  11. executor/packages/core/src/openapi-tool-builder.ts +2 −4
  12. executor/packages/core/src/tool-discovery.ts +2 −3
  13. executor/packages/core/src/tool-discovery/formatting.ts +2 −5
  14. executor/packages/core/src/type-hints.test.ts +21 −0
  15. executor/packages/core/src/type-hints.ts +184 −0
executor/apps/web/src/components/tools/explorer-tool-detail.tsx+2−2
@@ -20,8 +20,8 @@ export function ToolDetail({ · 19 unmodified lines
20 loading?: boolean;20 loading?: boolean;
21}) {21}) {
22 const insetLeft = depth * 20 + 8 + 16 + 8;22 const insetLeft = depth * 20 + 8 + 16 + 8;
23 const argsType = tool.strictArgsType?.trim() || tool.argsType;23 const argsType = tool.argsType?.trim() || tool.strictArgsType;
24 const returnsType = tool.strictReturnsType?.trim() || tool.returnsType;24 const returnsType = tool.returnsType?.trim() || tool.strictReturnsType;
25 const hasDetails = Boolean(tool.description || argsType || returnsType);25 const hasDetails = Boolean(tool.description || argsType || returnsType);
26 26
27 return (27 return (
executor/executor.ts+45−1
@@ -3,6 +3,7 @@ · 2 unmodified lines
3import fs from "node:fs/promises";3import fs from "node:fs/promises";
4import os from "node:os";4import os from "node:os";
5import path from "node:path";5import path from "node:path";
6import { checkBootstrapHealth } from "./packages/core/src/managed-runtime-bootstrap";
6import { managedRuntimeDiagnostics, runManagedBackend, runManagedWeb } from "./packages/core/src/managed-runtime";7import { managedRuntimeDiagnostics, runManagedBackend, runManagedWeb } from "./packages/core/src/managed-runtime";
7 8
8interface InstallPaths {9interface InstallPaths {
@@ -297,6 +298,18 @@ async function stopManagedServices(): Promise<{ backendStopped: boolean; webStop · 288 unmodified lines
297 webStopped = (await terminatePidTree(pid)) && webStopped;298 webStopped = (await terminatePidTree(pid)) && webStopped;
298 }299 }
299 300
301 const [remainingBackendPidsRaw, remainingWebPidsRaw] = await Promise.all([
302 listListeningPids(Number(Bun.env.EXECUTOR_BACKEND_PORT ?? 5410)),
303 listListeningPids(Number(Bun.env.EXECUTOR_WEB_PORT ?? 5312)),
304 ]);
305 const [remainingBackendPids, remainingWebPids] = await Promise.all([
306 filterManagedPids(remainingBackendPidsRaw, "backend"),
307 filterManagedPids(remainingWebPidsRaw, "web"),
308 ]);
309
310 backendStopped = remainingBackendPids.length === 0;
311 webStopped = remainingWebPids.length === 0;
312
300 await fs.rm(paths.backendPidFile, { force: true });313 await fs.rm(paths.backendPidFile, { force: true });
301 await fs.rm(paths.webPidFile, { force: true });314 await fs.rm(paths.webPidFile, { force: true });
302 315
@@ -341,11 +354,29 @@ async function runDoctor(args: string[]): Promise<number> { · 38 unmodified lines
341 const nodeInstalled = await pathExists(info.nodeBin);354 const nodeInstalled = await pathExists(info.nodeBin);
342 const backendRunning = await checkHttp(`${info.convexUrl}/version`);355 const backendRunning = await checkHttp(`${info.convexUrl}/version`);
343 const webRunning = await checkHttp(`http://127.0.0.1:${webPort}/`);356 const webRunning = await checkHttp(`http://127.0.0.1:${webPort}/`);
344 const healthy = nodeInstalled && webInstalled && backendRunning && webRunning;357
358 let bootstrapState: Awaited<ReturnType<typeof checkBootstrapHealth>> | null = null;
359 if (backendRunning) {
360 bootstrapState = await checkBootstrapHealth(info);
361 }
362
363 const functionsReady = bootstrapState?.state === "ready";
364 const healthy = nodeInstalled && webInstalled && backendRunning && webRunning && functionsReady;
345 365
346 console.log(`Executor status: ${healthy ? "ready" : "needs attention"}`);366 console.log(`Executor status: ${healthy ? "ready" : "needs attention"}`);
347 console.log(`Dashboard: http://127.0.0.1:${webPort} (${webRunning ? "running" : "not running"})`);367 console.log(`Dashboard: http://127.0.0.1:${webPort} (${webRunning ? "running" : "not running"})`);
348 console.log(`Backend: ${backendRunning ? "running" : "not running"} (${info.convexUrl})`);368 console.log(`Backend: ${backendRunning ? "running" : "not running"} (${info.convexUrl})`);
369 if (!backendRunning) {
370 console.log("Functions: unavailable (backend is not running)");
371 } else if (bootstrapState?.state === "ready") {
372 console.log("Functions: bootstrapped");
373 } else if (bootstrapState?.state === "no_project") {
374 console.log("Functions: not bootstrapped (no local Convex project found)");
375 } else if (bootstrapState?.state === "missing_functions") {
376 console.log("Functions: not bootstrapped (missing deployed functions)");
377 } else {
378 console.log("Functions: check failed");
379 }
349 console.log(`MCP endpoint: ${info.convexSiteUrl}/mcp`);380 console.log(`MCP endpoint: ${info.convexSiteUrl}/mcp`);
350 381
351 if (!backendRunning) {382 if (!backendRunning) {
@@ -354,6 +385,15 @@ async function runDoctor(args: string[]): Promise<number> { · 2 unmodified lines
354 if (!webRunning) {385 if (!webRunning) {
355 console.log("Next step: run `executor web`");386 console.log("Next step: run `executor web`");
356 }387 }
388 if (backendRunning && bootstrapState?.state === "no_project") {
389 console.log("Next step: run from your repo root or set `EXECUTOR_PROJECT_DIR` to a Convex project.");
390 }
391 if (backendRunning && bootstrapState?.state === "missing_functions") {
392 console.log("Next step: run `executor up` from your project root to bootstrap functions.");
393 }
394 if (backendRunning && bootstrapState?.state === "check_failed") {
395 console.log("Next step: run `executor up` and inspect ~/.executor/runtime/logs/backend.log");
396 }
357 if (!webInstalled || !nodeInstalled) {397 if (!webInstalled || !nodeInstalled) {
358 console.log("Missing runtime artifacts detected. Re-run install if needed.");398 console.log("Missing runtime artifacts detected. Re-run install if needed.");
359 }399 }
@@ -367,6 +407,10 @@ async function runDoctor(args: string[]): Promise<number> { · 7 unmodified lines
367 console.log(` convex site: ${info.convexSiteUrl}`);407 console.log(` convex site: ${info.convexSiteUrl}`);
368 console.log(` node runtime: ${nodeInstalled ? info.nodeBin : "missing"}`);408 console.log(` node runtime: ${nodeInstalled ? info.nodeBin : "missing"}`);
369 console.log(` web bundle: ${webInstalled ? info.webServerEntry : "missing"}`);409 console.log(` web bundle: ${webInstalled ? info.webServerEntry : "missing"}`);
410 console.log(` functions: ${bootstrapState?.state ?? "unavailable"}`);
411 if (bootstrapState?.detail) {
412 console.log(` functions detail: ${bootstrapState.detail}`);
413 }
370 console.log(` running: backend=${backendRunning ? "yes" : "no"} web=${webRunning ? "yes" : "no"}`);414 console.log(` running: backend=${backendRunning ? "yes" : "no"} web=${webRunning ? "yes" : "no"}`);
371 console.log(` config: ${info.configPath}`);415 console.log(` config: ${info.configPath}`);
372 }416 }
executor/install+34−0
@@ -293,8 +293,10 @@ start_background_services() { · 292 unmodified lines
293 293
294 if wait_for_http "http://127.0.0.1:${BACKEND_PORT}/version" 2; then294 if wait_for_http "http://127.0.0.1:${BACKEND_PORT}/version" 2; then
295 echo -e "${MUTED}Managed backend already running${NC} http://127.0.0.1:${BACKEND_PORT}"295 echo -e "${MUTED}Managed backend already running${NC} http://127.0.0.1:${BACKEND_PORT}"
296 "$INSTALL_DIR/$APP" up >>"$LOG_DIR/backend.log" 2>&1 || true
296 elif [[ -f "$BACKEND_PID_FILE" ]]; then297 elif [[ -f "$BACKEND_PID_FILE" ]]; then
297 echo -e "${MUTED}Managed backend already tracked${NC} (pid $(cat "$BACKEND_PID_FILE"))"298 echo -e "${MUTED}Managed backend already tracked${NC} (pid $(cat "$BACKEND_PID_FILE"))"
299 "$INSTALL_DIR/$APP" up >>"$LOG_DIR/backend.log" 2>&1 || true
298 else300 else
299 nohup "$INSTALL_DIR/$APP" up >"$LOG_DIR/backend.log" 2>&1 &301 nohup "$INSTALL_DIR/$APP" up >"$LOG_DIR/backend.log" 2>&1 &
300 echo "$!" > "$BACKEND_PID_FILE"302 echo "$!" > "$BACKEND_PID_FILE"
@@ -322,6 +324,37 @@ start_background_services() { · 21 unmodified lines
322 fi324 fi
323}325}
324 326
327wait_for_executor_ready() {
328 local max_seconds="${1:-120}"
329 local started
330 started="$(date +%s)"
331
332 while true; do
333 if "$INSTALL_DIR/$APP" doctor >/dev/null 2>&1; then
334 echo -e "${MUTED}Executor services healthy${NC}"
335 return 0
336 fi
337
338 local now
339 now="$(date +%s)"
340 if (( now - started >= max_seconds )); then
341 break
342 fi
343
344 sleep 0.5
345 done
346
347 echo -e "${RED}Executor services are running but not fully ready yet.${NC}"
348 echo " Check: $INSTALL_DIR/$APP doctor --verbose"
349 if [[ -f "$LOG_DIR/backend.log" ]]; then
350 echo " Backend log: $LOG_DIR/backend.log"
351 fi
352 if [[ -f "$LOG_DIR/web.log" ]]; then
353 echo " Web log: $LOG_DIR/web.log"
354 fi
355 return 1
356}
357
325install_web_bundle358install_web_bundle
326 359
327if [[ "$NO_MODIFY_PATH" != "1" ]]; then360if [[ "$NO_MODIFY_PATH" != "1" ]]; then
@@ -341,6 +374,7 @@ if [[ "$NO_MODIFY_PATH" != "1" ]]; then · 13 unmodified lines
341fi374fi
342 375
343start_background_services376start_background_services
377wait_for_executor_ready || true
344 378
345echo ""379echo ""
346echo "Installed to: $INSTALL_DIR/$APP"380echo "Installed to: $INSTALL_DIR/$APP"
executor/packages/convex/auth.config.ts+2−4
@@ -1,7 +1,5 @@
1const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;1const rawClientId = process.env.WORKOS_CLIENT_ID?.trim();
2const workosClientIdKey = ["WORKOS", "CLIENT", "ID"].join("_");2const clientId = rawClientId && rawClientId !== "disabled" ? rawClientId : undefined;
3const rawClientId = env?.[workosClientIdKey]?.trim();
4const clientId = rawClientId && rawClientId.length > 0 ? rawClientId : undefined;
5 3
6const authConfig = clientId4const authConfig = clientId
7 ? {5 ? {
executor/packages/convex/http-anonymous-mcp.e2e.test.ts+0−90
@@ -1,90 +0,0 @@
1import { expect, test } from "bun:test";
2import { convexTest } from "convex-test";
3import { internal } from "./_generated/api";
4import schema from "./schema";
5
6function setup() {
7 return convexTest(schema, {
8 "./database.ts": () => import("./database"),
9 "./executor.ts": () => import("./executor"),
10 "./executorNode.ts": () => import("./executorNode"),
11 "./http.ts": () => import("./http"),
12 "./auth.ts": () => import("./auth"),
13 "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),
14 "./workspaceToolCache.ts": () => import("./workspaceToolCache"),
15 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
16 "./_generated/api.js": () => import("./_generated/api.js"),
17 });
18}
19
20test("anonymous MCP endpoint allows direct access without OAuth token", async () => {
21 const t = setup();
22 const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
23 const query = new URLSearchParams({
24 workspaceId: session.workspaceId,
25 actorId: session.actorId,
26 }).toString();
27
28 const anonymousRes = await t.fetch(`/mcp/anonymous?${query}`, {
29 method: "POST",
30 headers: {
31 "content-type": "application/json",
32 },
33 body: JSON.stringify({}),
34 });
35 expect(anonymousRes.status).not.toBe(401);
36 expect(anonymousRes.status).not.toBe(403);
37
38 const otherSession = await t.mutation(internal.database.bootstrapAnonymousSession, {});
39 const mismatchedQuery = new URLSearchParams({
40 workspaceId: otherSession.workspaceId,
41 actorId: session.actorId,
42 }).toString();
43 const mismatchedRes = await t.fetch(`/mcp/anonymous?${mismatchedQuery}`, {
44 method: "POST",
45 headers: {
46 "content-type": "application/json",
47 },
48 body: JSON.stringify({}),
49 });
50 expect(mismatchedRes.status).toBe(403);
51
52 const wrongEndpointRes = await t.fetch(`/mcp?${query}`, {
53 method: "POST",
54 headers: {
55 "content-type": "application/json",
56 },
57 body: JSON.stringify({}),
58 });
59 expect(wrongEndpointRes.status).toBe(400);
60});
61
62test("OAuth discovery only applies to /mcp and not /mcp/anonymous", async () => {
63 const previousAuthorizationServer = process.env.MCP_AUTHORIZATION_SERVER;
64 process.env.MCP_AUTHORIZATION_SERVER = "https://victorious-point-35-staging.authkit.app";
65
66 const t = setup();
67 try {
68 const protectedResourceRes = await t.fetch("/.well-known/oauth-protected-resource?workspaceId=ms7ewkx14rnc73wwnqsj80cngd812kqt");
69 expect(protectedResourceRes.status).toBe(200);
70 const protectedResourceBody = await protectedResourceRes.json() as {
71 authorization_servers: string[];
72 resource: string;
73 };
74 expect(protectedResourceBody.authorization_servers[0]).toBe(process.env.MCP_AUTHORIZATION_SERVER);
75 const protectedResourceUrl = new URL(protectedResourceBody.resource);
76 expect(protectedResourceUrl.pathname).toBe("/mcp");
77
78 const anonymousResource = encodeURIComponent(
79 `${protectedResourceUrl.origin}/mcp/anonymous?workspaceId=ms7ewkx14rnc73wwnqsj80cngd812kqt&actorId=anon_123`,
80 );
81 const anonymousRes = await t.fetch(`/.well-known/oauth-protected-resource?resource=${anonymousResource}`);
82 expect(anonymousRes.status).toBe(404);
83 } finally {
84 if (previousAuthorizationServer === undefined) {
85 delete process.env.MCP_AUTHORIZATION_SERVER;
86 } else {
87 process.env.MCP_AUTHORIZATION_SERVER = previousAuthorizationServer;
88 }
89 }
90});
executor/packages/convex/runtime/tool_source_loading.ts+1−1
@@ -20,7 +20,7 @@ import type { ToolSourceRecord } from "../../core/src/types"; · 19 unmodified lines
20const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;20const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
21 21
22/** Cache version - bump when PreparedOpenApiSpec shape changes. */22/** Cache version - bump when PreparedOpenApiSpec shape changes. */
23const TOOL_SOURCE_CACHE_VERSION = "v16";23const TOOL_SOURCE_CACHE_VERSION = "v17";
24 24
25export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {25export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
26 const parts = sources26 const parts = sources
executor/packages/core/src/managed-runtime-bootstrap.ts+68−9
@@ -122,6 +122,7 @@ async function writeBootstrapEnvFile(info: ManagedRuntimeInfo, adminKey: string) · 121 unmodified lines
122 const contents = [122 const contents = [
123 `CONVEX_SELF_HOSTED_URL=http://${info.config.hostInterface}:${info.config.backendPort}`,123 `CONVEX_SELF_HOSTED_URL=http://${info.config.hostInterface}:${info.config.backendPort}`,
124 `CONVEX_SELF_HOSTED_ADMIN_KEY=${adminKey}`,124 `CONVEX_SELF_HOSTED_ADMIN_KEY=${adminKey}`,
125 "WORKOS_CLIENT_ID=disabled",
125 ].join("\n");126 ].join("\n");
126 await fs.writeFile(filePath, `${contents}\n`, "utf8");127 await fs.writeFile(filePath, `${contents}\n`, "utf8");
127 return filePath;128 return filePath;
@@ -173,15 +174,16 @@ export async function waitForBackendReady(info: ManagedRuntimeInfo, timeoutMs = · 45 unmodified lines
173 throw new Error("Timed out waiting for local Convex backend to become ready.");174 throw new Error("Timed out waiting for local Convex backend to become ready.");
174}175}
175 176
176export async function ensureProjectBootstrapped(info: ManagedRuntimeInfo): Promise<void> {177export type BootstrapHealth = {
177 if (Bun.env.EXECUTOR_SKIP_BOOTSTRAP === "1") {178 state: "ready" | "no_project" | "missing_functions" | "check_failed";
178 return;179 projectDir?: string;
179 }180 detail?: string;
181};
180 182
183export async function checkBootstrapHealth(info: ManagedRuntimeInfo): Promise<BootstrapHealth> {
181 const projectDir = await findProjectDir();184 const projectDir = await findProjectDir();
182 if (!projectDir) {185 if (!projectDir) {
183 console.log("[executor] no local Convex project found; skipping function bootstrap");186 return { state: "no_project" };
184 return;
185 }187 }
186 188
187 await ensureConvexCliRuntime(info);189 await ensureConvexCliRuntime(info);
@@ -189,26 +191,83 @@ export async function ensureProjectBootstrapped(info: ManagedRuntimeInfo): Promi · 1 unmodified line
189 const envFilePath = await writeBootstrapEnvFile(info, adminKey);191 const envFilePath = await writeBootstrapEnvFile(info, adminKey);
190 192
191 try {193 try {
194 const ensureWorkosClientId = await runManagedConvexCli(
195 info,
196 projectDir,
197 ["env", "set", "WORKOS_CLIENT_ID", "disabled"],
198 envFilePath,
199 {
200 stdout: "pipe",
201 stderr: "pipe",
202 },
203 );
204 if (ensureWorkosClientId.exitCode !== 0) {
205 const detail = ensureWorkosClientId.stderr.trim() || ensureWorkosClientId.stdout.trim() || "unknown error";
206 console.warn(`[executor] could not seed WORKOS_CLIENT_ID in local backend env: ${detail}`);
207 }
208
192 const check = await runManagedConvexCli(info, projectDir, ["run", "app:getClientConfig"], envFilePath, {209 const check = await runManagedConvexCli(info, projectDir, ["run", "app:getClientConfig"], envFilePath, {
193 stdout: "pipe",210 stdout: "pipe",
194 stderr: "pipe",211 stderr: "pipe",
195 });212 });
196 213
197 if (check.exitCode === 0) {214 if (check.exitCode === 0) {
198 console.log("[executor] Convex functions already bootstrapped");215 return { state: "ready", projectDir };
199 return;
200 }216 }
201 217
218 const detail = check.stderr.trim() || check.stdout.trim() || `exit ${check.exitCode}`;
219 return { state: "missing_functions", projectDir, detail };
220 } catch (error) {
221 const detail = error instanceof Error ? error.message : String(error);
222 return { state: "check_failed", projectDir, detail };
223 } finally {
224 await fs.rm(path.dirname(envFilePath), { recursive: true, force: true });
225 }
226}
227
228export async function ensureProjectBootstrapped(info: ManagedRuntimeInfo): Promise<void> {
229 if (Bun.env.EXECUTOR_SKIP_BOOTSTRAP === "1") {
230 return;
231 }
232
233 const check = await checkBootstrapHealth(info);
234
235 if (check.state === "no_project") {
236 console.log("[executor] no local Convex project found; skipping function bootstrap");
237 return;
238 }
239
240 if (check.state === "ready") {
241 console.log("[executor] Convex functions already bootstrapped");
242 return;
243 }
244
245 if (check.state === "check_failed") {
246 const detail = check.detail ? `: ${check.detail}` : "";
247 throw new Error(`Convex bootstrap preflight failed${detail}`);
248 }
249
250 const projectDir = check.projectDir;
251 if (!projectDir) {
252 throw new Error("Convex bootstrap failed to resolve project directory.");
253 }
254
255 const adminKey = await generateSelfHostedAdminKey(info);
256 const envFilePath = await writeBootstrapEnvFile(info, adminKey);
257
258 try {
202 console.log("[executor] bootstrapping Convex functions to local backend");259 console.log("[executor] bootstrapping Convex functions to local backend");
203 const deploy = await runManagedConvexCli(260 const deploy = await runManagedConvexCli(
204 info,261 info,
205 projectDir,262 projectDir,
206 ["deploy", "--yes", "--typecheck", "disable", "--codegen", "disable"],263 ["deploy", "--yes", "--typecheck", "disable", "--codegen", "disable"],
207 envFilePath,264 envFilePath,
265 { stdout: "pipe", stderr: "pipe" },
208 );266 );
209 267
210 if (deploy.exitCode !== 0) {268 if (deploy.exitCode !== 0) {
211 throw new Error("Convex bootstrap failed while deploying local functions.");269 const detail = deploy.stderr.trim() || deploy.stdout.trim() || "unknown deploy error";
270 throw new Error(`Convex bootstrap failed while deploying local functions: ${detail}`);
212 }271 }
213 } finally {272 } finally {
214 await fs.rm(path.dirname(envFilePath), { recursive: true, force: true });273 await fs.rm(path.dirname(envFilePath), { recursive: true, force: true });
executor/packages/core/src/mcp-server-formatting.ts+1−1
@@ -55,7 +55,7 @@ export function buildRunCodeDescription(tools?: ToolDescriptor[]): string { · 54 unmodified lines
55 ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}`55 ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}`
56 : "";56 : "";
57 const hasGraphqlTools = toolList.some((tool) => tool.path.endsWith(".graphql"));57 const hasGraphqlTools = toolList.some((tool) => tool.path.endsWith(".graphql"));
58 const discoverNote = "\n\nTooling tip: avoid repeated tiny discovery calls. Start with a single broad inventory pass via `tools.catalog.namespaces({})` and `tools.catalog.tools({ namespace?, query?, compact: false, depth: 2, limit: 20 })` to get typed signatures, then do at most one focused `tools.discover({ query, compact: false, depth: 2, limit: 12 })`. `discover` returns `{ bestPath, results, total }`; prefer `bestPath` when present, otherwise copy `results[i].exampleCall`. Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`).";58 const discoverNote = "\n\nTooling tip: avoid repeated tiny discovery calls. Start with a single broad inventory pass via `tools.catalog.namespaces({})` and `tools.catalog.tools({ namespace?, query?, compact: true, depth: 1, limit: 20 })`, then do at most one focused `tools.discover({ query, compact: true, depth: 1, limit: 12 })`. If you still need full signatures for a final pick, rerun only that shortlist with `compact: false`. `discover` returns `{ bestPath, results, total }`; prefer `bestPath` when present, otherwise copy `results[i].exampleCall`. Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`).";
59 const executionNote = "\n\nExecution tip: for migration/ETL-style tasks, discover once, then run in small batches and `return` compact summaries (counts, IDs, and top-N samples) instead of full objects.";59 const executionNote = "\n\nExecution tip: for migration/ETL-style tasks, discover once, then run in small batches and `return` compact summaries (counts, IDs, and top-N samples) instead of full objects.";
60 const graphqlNote = hasGraphqlTools60 const graphqlNote = hasGraphqlTools
61 ? "\n\nGraphQL tip: prefer `source.query.*` / `source.mutation.*` helper paths when available; GraphQL tools return `{ data, errors }`."61 ? "\n\nGraphQL tip: prefer `source.query.*` / `source.mutation.*` helper paths when available; GraphQL tools return `{ data, errors }`."
executor/packages/core/src/openapi-cache.test.ts+48−2
@@ -456,12 +456,58 @@ describe("buildOpenApiToolsFromPrepared", () => { · 455 unmodified lines
456 const tool = tools.find((candidate) => candidate.metadata?.operationId === "createRecord");456 const tool = tools.find((candidate) => candidate.metadata?.operationId === "createRecord");
457 expect(tool).toBeDefined();457 expect(tool).toBeDefined();
458 expect(tool?.metadata?.argPreviewKeys).toEqual(["domain", "type", "name", "value"]);458 expect(tool?.metadata?.argPreviewKeys).toEqual(["domain", "type", "name", "value"]);
459 expect(tool?.metadata?.displayArgsType).toContain("name: ...");459 expect(tool?.metadata?.displayArgsType).toContain("name: string");
460 expect(tool?.metadata?.displayArgsType).toContain("value: ...");460 expect(tool?.metadata?.displayArgsType).toContain("value: string");
461 expect(tool?.metadata?.argsType).toContain("name");461 expect(tool?.metadata?.argsType).toContain("name");
462 expect(tool?.metadata?.argsType).toContain("value");462 expect(tool?.metadata?.argsType).toContain("value");
463 });463 });
464 464
465 test("keeps concrete primitive type for compact single-arg hints", async () => {
466 const spec: Record<string, unknown> = {
467 openapi: "3.0.3",
468 info: { title: "Threads", version: "1.0.0" },
469 servers: [{ url: "https://api.example.com" }],
470 paths: {
471 "/threads/{thread_id}/subscription": {
472 delete: {
473 operationId: "deleteThreadSubscription",
474 tags: ["activity"],
475 summary: "Delete a thread subscription",
476 parameters: [
477 {
478 name: "thread_id",
479 in: "path",
480 required: true,
481 schema: { type: "string" },
482 },
483 ],
484 responses: {
485 "204": {
486 description: "deleted",
487 },
488 },
489 },
490 },
491 },
492 };
493
494 const prepared = await prepareOpenApiSpec(spec, "threads");
495 const tools = buildOpenApiToolsFromPrepared(
496 {
497 type: "openapi",
498 name: "github",
499 spec,
500 baseUrl: "https://api.example.com",
501 },
502 prepared,
503 );
504
505 const tool = tools.find((candidate) => candidate.metadata?.operationId === "deleteThreadSubscription");
506 expect(tool).toBeDefined();
507 expect(tool?.metadata?.displayArgsType).toBe("{ thread_id: string }");
508 expect(tool?.metadata?.returnsType).toBe("void");
509 });
510
465 test("infers workspace auth from OpenAPI securitySchemes when source auth is unset", async () => {511 test("infers workspace auth from OpenAPI securitySchemes when source auth is unset", async () => {
466 const spec: Record<string, unknown> = {512 const spec: Record<string, unknown> = {
467 openapi: "3.0.3",513 openapi: "3.0.3",
executor/packages/core/src/openapi-real-specs.test.ts+179−0
@@ -8,6 +8,53 @@ · 7 unmodified lines
8 */8 */
9import { test, expect, describe } from "bun:test";9import { test, expect, describe } from "bun:test";
10import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool-sources";10import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool-sources";
11import { generateToolDeclarations, typecheckCode } from "./typechecker";
12import type { ToolDefinition, ToolDescriptor } from "./types";
13
14function compareStrictHintsToOpenApiTypes(
15 tool: ToolDefinition,
16 sourceName: string,
17 sourceDts: string,
18) {
19 const operationId = tool.metadata?.operationId;
20 if (!operationId) {
21 throw new Error(`Tool ${tool.path} is missing operationId`);
22 }
23
24 const descriptor: ToolDescriptor = {
25 path: tool.path,
26 description: tool.description,
27 approval: tool.approval,
28 source: tool.source,
29 operationId,
30 argsType: tool.metadata?.displayArgsType,
31 returnsType: tool.metadata?.displayReturnsType,
32 strictArgsType: tool.metadata?.argsType,
33 strictReturnsType: tool.metadata?.returnsType,
34 };
35
36 const declarations = generateToolDeclarations([descriptor], {
37 sourceDtsBySource: {
38 [`openapi:${sourceName}`]: sourceDts,
39 },
40 });
41
42 const strictArgs = tool.metadata?.argsType ?? "{}";
43 const strictReturns = tool.metadata?.returnsType ?? "unknown";
44 const code = [
45 `type GeneratedInput = ToolInput<operations[${JSON.stringify(operationId)}]>`,
46 `type StrictInput = ${strictArgs}`,
47 "const _inputA: GeneratedInput = {} as StrictInput",
48 "const _inputB: StrictInput = {} as GeneratedInput",
49 `type GeneratedOutput = ToolOutput<operations[${JSON.stringify(operationId)}]>`,
50 `type StrictOutput = ${strictReturns}`,
51 "const _outputA: GeneratedOutput = {} as StrictOutput",
52 "const _outputB: StrictOutput = {} as GeneratedOutput",
53 "return true;",
54 ].join("\n");
55
56 return typecheckCode(code, declarations);
57}
11 58
12interface SpecFixture {59interface SpecFixture {
13 name: string;60 name: string;
@@ -206,6 +253,38 @@ describe("real-world OpenAPI specs", () => { · 192 unmodified lines
206 300_000,253 300_000,
207 );254 );
208 255
256 test(
257 "github: add custom labels to repo runner keeps concrete compact arg hints",
258 async () => {
259 const githubUrl =
260 "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml";
261
262 const prepared = await prepareOpenApiSpec(githubUrl, "github", { includeDts: false });
263 const tools = buildOpenApiToolsFromPrepared(
264 {
265 type: "openapi",
266 name: "github",
267 spec: githubUrl,
268 baseUrl: prepared.servers[0] || "https://api.github.com",
269 },
270 prepared,
271 );
272
273 const tool = tools.find(
274 (t) => t.metadata?.operationId === "actions/add-custom-labels-to-self-hosted-runner-for-repo",
275 );
276
277 expect(tool).toBeDefined();
278 expect(tool!.path).toBe("github.actions.add_custom_labels_to_self_hosted_runner_for_repo");
279 expect(tool!.metadata!.displayArgsType).toContain("owner: string");
280 expect(tool!.metadata!.displayArgsType).toContain("repo: string");
281 expect(tool!.metadata!.displayArgsType).toContain("runner_id: number");
282 expect(tool!.metadata!.displayArgsType).toContain("labels: string[]");
283 expect(tool!.metadata!.displayArgsType).not.toContain("owner: ...");
284 },
285 300_000,
286 );
287
209 test(288 test(
210 "github: create hosted runner for org has non-unknown return hint",289 "github: create hosted runner for org has non-unknown return hint",
211 async () => {290 async () => {
@@ -378,4 +457,104 @@ describe("real-world OpenAPI specs", () => { · 166 unmodified lines
378 },457 },
379 300_000,458 300_000,
380 );459 );
460
461 test(
462 "cloudflare: access authentication logs keeps non-ellipsis compact hints",
463 async () => {
464 const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
465
466 const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare", { includeDts: false });
467 const tools = buildOpenApiToolsFromPrepared(
468 {
469 type: "openapi",
470 name: "cloudflare",
471 spec: cloudflareUrl,
472 baseUrl: prepared.servers[0] || "https://api.cloudflare.com/client/v4",
473 },
474 prepared,
475 );
476
477 const tool = tools.find(
478 (t) => t.metadata?.operationId === "access-authentication-logs-get-access-authentication-logs",
479 );
480
481 expect(tool).toBeDefined();
482 expect(tool!.path).toBe("cloudflare.access_authentication_logs.get_access_authentication_logs");
483 expect(tool!.metadata!.displayArgsType).toContain("account_id: string");
484 expect(tool!.metadata!.displayArgsType).toContain("direction?: \"desc\" | \"asc\"");
485 expect(tool!.metadata!.displayArgsType).not.toContain("...");
486 expect(tool!.metadata!.displayReturnsType).toContain("messages: { code: number");
487 expect(tool!.metadata!.displayReturnsType).toContain("result?: {");
488 expect(tool!.metadata!.displayReturnsType).not.toContain("...");
489 },
490 300_000,
491 );
492
493 test(
494 "cloudflare: delete access application keeps concrete compact args/returns",
495 async () => {
496 const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
497
498 const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare", { includeDts: false });
499 const tools = buildOpenApiToolsFromPrepared(
500 {
501 type: "openapi",
502 name: "cloudflare",
503 spec: cloudflareUrl,
504 baseUrl: prepared.servers[0] || "https://api.cloudflare.com/client/v4",
505 },
506 prepared,
507 );
508
509 const tool = tools.find(
510 (t) => t.metadata?.operationId === "access-applications-delete-an-access-application",
511 );
512
513 expect(tool).toBeDefined();
514 expect(tool!.metadata!.displayArgsType).toBe("{ app_id: string; account_id: string }");
515 expect(tool!.metadata!.displayArgsType).not.toContain("...");
516 expect(tool!.metadata!.displayReturnsType).toContain("result?: { id?: string }");
517 expect(tool!.metadata!.displayReturnsType).not.toContain("num...");
518 },
519 300_000,
520 );
521
522 test(
523 "cloudflare: add access application type hints diverge from openapi-typescript canonical types",
524 async () => {
525 const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
526
527 const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare");
528 const tools = buildOpenApiToolsFromPrepared(
529 {
530 type: "openapi",
531 name: "cloudflare",
532 spec: cloudflareUrl,
533 baseUrl: prepared.servers[0] || "https://api.cloudflare.com/client/v4",
534 },
535 prepared,
536 );
537
538 const tool = tools.find(
539 (t) => t.metadata?.operationId === "access-applications-add-an-application",
540 );
541
542 expect(tool).toBeDefined();
543 expect(prepared.dts).toBeDefined();
544
545 const comparison = compareStrictHintsToOpenApiTypes(tool!, "cloudflare", prepared.dts!);
546
547 // Reproduces the current mismatch between fallback strict hints and generated operation types.
548 expect(comparison.ok).toBe(false);
549 expect(
550 comparison.errors.some((error) => error.includes("allowed_methods") || error.includes("target_criteria")),
551 ).toBe(true);
552
553 // Ensure UI/discovery hints remain compact even when strict hints are very large.
554 expect(tool!.metadata!.displayArgsType).toContain("account_id");
555 expect(tool!.metadata!.displayArgsType!.length).toBeLessThan(240);
556 expect(tool!.metadata!.argsType!.length).toBeGreaterThan(8_000);
557 },
558 300_000,
559 );
381});560});
executor/packages/core/src/openapi-tool-builder.ts+2−4
@@ -9,7 +9,7 @@ import { · 8 unmodified lines
9import { buildOpenApiToolPath } from "./openapi-tool-path";9import { buildOpenApiToolPath } from "./openapi-tool-path";
10import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "./tool-source-auth";10import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "./tool-source-auth";
11import { executeOpenApiRequest } from "./tool-source-execution";11import { executeOpenApiRequest } from "./tool-source-execution";
12import { compactArgKeysHint, compactArgTypeHint, compactReturnTypeHint } from "./type-hints";12import { compactArgDisplayHint, compactReturnTypeHint } from "./type-hints";
13import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "./tool-source-types";13import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "./tool-source-types";
14import type { ToolDefinition } from "./types";14import type { ToolDefinition } from "./types";
15import { asRecord } from "./utils";15import { asRecord } from "./utils";
@@ -135,9 +135,7 @@ export function buildOpenApiToolsFromPrepared( · 119 unmodified lines
135 135
136 const { argsType, returnsType, argPreviewKeys } = buildOpenApiOperationTypeHints(operation, parameters);136 const { argsType, returnsType, argPreviewKeys } = buildOpenApiOperationTypeHints(operation, parameters);
137 137
138 const displayArgsType = argPreviewKeys.length > 0138 const displayArgsType = compactArgDisplayHint(argsType, argPreviewKeys);
139 ? compactArgKeysHint(argPreviewKeys)
140 : compactArgTypeHint(argsType);
141 const displayReturnsType = compactReturnTypeHint(returnsType);139 const displayReturnsType = compactReturnTypeHint(returnsType);
142 140
143 const approval = config.overrides?.[operationIdRaw]?.approval141 const approval = config.overrides?.[operationIdRaw]?.approval
executor/packages/core/src/tool-discovery.ts+2−3
@@ -1,7 +1,6 @@
1import type { ToolDefinition } from "./types";1import type { ToolDefinition } from "./types";
2import {2import {
3 compactArgKeysHint,3 compactArgDisplayHint,
4 compactArgTypeHint,
5 compactDescriptionLine,4 compactDescriptionLine,
6 compactReturnTypeHint,5 compactReturnTypeHint,
7} from "./type-hints";6} from "./type-hints";
@@ -95,7 +94,7 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 87 unmodified lines
95 approval: entry.approval,94 approval: entry.approval,
96 description: compact ? compactDescriptionLine(entry.description) : entry.description,95 description: compact ? compactDescriptionLine(entry.description) : entry.description,
97 argsType: compact96 argsType: compact
98 ? (entry.argPreviewKeys.length > 0 ? compactArgKeysHint(entry.argPreviewKeys) : compactArgTypeHint(entry.argsType))97 ? compactArgDisplayHint(entry.argsType, entry.argPreviewKeys)
99 : entry.argsType,98 : entry.argsType,
100 returnsType: compact ? compactReturnTypeHint(entry.returnsType) : entry.returnsType,99 returnsType: compact ? compactReturnTypeHint(entry.returnsType) : entry.returnsType,
101 signature: formatSignature(entry, depth, compact),100 signature: formatSignature(entry, depth, compact),
executor/packages/core/src/tool-discovery/formatting.ts+2−5
@@ -1,6 +1,5 @@
1import {1import {
2 compactArgKeysHint,2 compactArgDisplayHint,
3 compactArgTypeHint,
4 compactReturnTypeHint,3 compactReturnTypeHint,
5 extractTopLevelTypeKeys,4 extractTopLevelTypeKeys,
6} from "../type-hints";5} from "../type-hints";
@@ -35,9 +34,7 @@ export function formatSignature(entry: DiscoverIndexEntry, depth: number, compac · 28 unmodified lines
35 return "(input: ...): Promise<...>";34 return "(input: ...): Promise<...>";
36 }35 }
37 36
38 const args = entry.argPreviewKeys.length > 037 const args = compactArgDisplayHint(entry.argsType, entry.argPreviewKeys);
39 ? compactArgKeysHint(entry.argPreviewKeys)
40 : compactArgTypeHint(entry.argsType);
41 const returns = compactReturnTypeHint(entry.returnsType);38 const returns = compactReturnTypeHint(entry.returnsType);
42 39
43 if (depth === 1) {40 if (depth === 1) {
executor/packages/core/src/type-hints.test.ts+21−0
@@ -19,6 +19,18 @@ test("compactArgTypeHint keeps only top-level previews", async () => { · 18 unmodified lines
19 expect(compact).toBe("{ parent: ...; title: ...; icon: ...; properties: ... }");19 expect(compact).toBe("{ parent: ...; title: ...; icon: ...; properties: ... }");
20});20});
21 21
22test("compactArgTypeHint flattens simple object intersections", async () => {
23 const compact = compactArgTypeHint(
24 "{ owner: string; repo: string; runner_id: number } & { labels: string[] }",
25 );
26 expect(compact).toBe("{ owner: string; repo: string; runner_id: number; labels: string[] }");
27});
28
29test("compactArgTypeHint dedupes trivial unions while preserving concrete types", async () => {
30 const compact = compactArgTypeHint("{ app_id: string | string; account_id: string }");
31 expect(compact).toBe("{ app_id: string; account_id: string }");
32});
33
22test("compactArgKeysHint caps key list with ellipsis", async () => {34test("compactArgKeysHint caps key list with ellipsis", async () => {
23 const compact = compactArgKeysHint(["a", "b", "c", "d", "e", "f", "g", "h"]);35 const compact = compactArgKeysHint(["a", "b", "c", "d", "e", "f", "g", "h"]);
24 expect(compact).toBe("{ a: ...; b: ...; c: ...; d: ...; e: ...; f: ...; ... }");36 expect(compact).toBe("{ a: ...; b: ...; c: ...; d: ...; e: ...; f: ...; ... }");
@@ -29,6 +41,15 @@ test("compactReturnTypeHint collapses graphql envelopes", async () => { · 4 unmodified lines
29 expect(compact).toBe("{ data: ...; errors: unknown[] }");41 expect(compact).toBe("{ data: ...; errors: unknown[] }");
30});42});
31 43
44test("compactReturnTypeHint flattens simple object intersections", async () => {
45 const compact = compactReturnTypeHint(
46 "{ errors: { code: number }[]; messages: { code: number }[]; success: true } & { result?: { id?: string } }",
47 );
48 expect(compact).toBe(
49 "{ errors: { code: number }[]; messages: { code: number }[]; success: true; result?: { id?: string } }",
50 );
51});
52
32test("compactDescriptionLine trims to a single concise line", async () => {53test("compactDescriptionLine trims to a single concise line", async () => {
33 const compact = compactDescriptionLine("Create a database in Notion.\nThis endpoint supports parent/page context.");54 const compact = compactDescriptionLine("Create a database in Notion.\nThis endpoint supports parent/page context.");
34 expect(compact).toBe("Create a database in Notion.");55 expect(compact).toBe("Create a database in Notion.");
executor/packages/core/src/type-hints.ts+184−0
@@ -4,6 +4,134 @@ function truncateInline(value: string, maxLength: number): string { · 3 unmodified lines
4 return `${normalized.slice(0, Math.max(16, maxLength - 3)).trim()}...`;4 return `${normalized.slice(0, Math.max(16, maxLength - 3)).trim()}...`;
5}5}
6 6
7function splitTopLevelBy(value: string, separator: string): string[] {
8 const parts: string[] = [];
9 let segment = "";
10 let depthCurly = 0;
11 let depthSquare = 0;
12 let depthParen = 0;
13 let depthAngle = 0;
14
15 for (const char of value) {
16 if (char === "{") depthCurly += 1;
17 else if (char === "}") depthCurly = Math.max(0, depthCurly - 1);
18 else if (char === "[") depthSquare += 1;
19 else if (char === "]") depthSquare = Math.max(0, depthSquare - 1);
20 else if (char === "(") depthParen += 1;
21 else if (char === ")") depthParen = Math.max(0, depthParen - 1);
22 else if (char === "<") depthAngle += 1;
23 else if (char === ">") depthAngle = Math.max(0, depthAngle - 1);
24
25 if (
26 char === separator
27 && depthCurly === 0
28 && depthSquare === 0
29 && depthParen === 0
30 && depthAngle === 0
31 ) {
32 const trimmed = segment.trim();
33 if (trimmed.length > 0) parts.push(trimmed);
34 segment = "";
35 continue;
36 }
37
38 segment += char;
39 }
40
41 const trimmed = segment.trim();
42 if (trimmed.length > 0) parts.push(trimmed);
43 return parts;
44}
45
46function normalizeSimpleUnion(typeExpression: string): string {
47 const parts = splitTopLevelBy(typeExpression, "|")
48 .map((part) => part.trim())
49 .filter((part) => part.length > 0);
50 if (parts.length < 2) return typeExpression;
51 if (parts.some((part) => /[{};]/.test(part))) return typeExpression;
52
53 const unique: string[] = [];
54 for (const part of parts) {
55 if (!unique.includes(part)) unique.push(part);
56 }
57
58 if (unique.length === parts.length) return typeExpression;
59 return unique.join(" | ");
60}
61
62function normalizeFlatObjectHint(typeHint: string): string | null {
63 const trimmed = typeHint.trim();
64 if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
65
66 const inner = trimmed.slice(1, -1);
67 const segments = splitTopLevelBy(inner, ";");
68 if (segments.length === 0) return null;
69
70 const normalizedSegments: string[] = [];
71 for (const rawSegment of segments) {
72 const segment = rawSegment.trim();
73 if (!segment) continue;
74
75 const colonIndex = segment.indexOf(":");
76 if (colonIndex <= 0) {
77 normalizedSegments.push(segment);
78 continue;
79 }
80
81 const key = segment.slice(0, colonIndex).trim();
82 const value = normalizeSimpleUnion(segment.slice(colonIndex + 1).trim());
83 normalizedSegments.push(`${key}: ${value}`);
84 }
85
86 if (normalizedSegments.length === 0) return null;
87 return `{ ${normalizedSegments.join("; ")} }`;
88}
89
90function normalizeIntersectionObjectHint(
91 typeHint: string,
92 options: { maxParts?: number; maxKeys?: number; maxLength?: number } = {},
93): string | null {
94 const maxParts = options.maxParts ?? 6;
95 const maxKeys = options.maxKeys ?? 8;
96 const maxLength = options.maxLength ?? 140;
97 const parts = splitTopLevelBy(typeHint, "&");
98 if (parts.length < 2 || parts.length > maxParts) return null;
99
100 const orderedKeys: string[] = [];
101 const keyToType = new Map<string, string>();
102
103 for (const part of parts) {
104 const trimmed = part.trim();
105 if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
106
107 const inner = trimmed.slice(1, -1);
108 const segments = splitTopLevelBy(inner, ";");
109 for (const rawSegment of segments) {
110 const segment = rawSegment.trim();
111 if (!segment || segment.startsWith("[")) continue;
112 const colonIndex = segment.indexOf(":");
113 if (colonIndex <= 0) continue;
114
115 const key = segment.slice(0, colonIndex).trim().replace(/["']/g, "");
116 if (!key) continue;
117 const value = normalizeSimpleUnion(segment.slice(colonIndex + 1).trim());
118 if (!value) continue;
119
120 if (!keyToType.has(key)) {
121 orderedKeys.push(key);
122 keyToType.set(key, value);
123 }
124 }
125 }
126
127 if (orderedKeys.length === 0 || orderedKeys.length > maxKeys) return null;
128
129 const entries = orderedKeys.map((key) => `${key}: ${keyToType.get(key)}`);
130 const compact = `{ ${entries.join("; ")} }`;
131 if (compact.length > maxLength) return null;
132 return compact;
133}
134
7export function extractTopLevelTypeKeys(typeHint: string): string[] {135export function extractTopLevelTypeKeys(typeHint: string): string[] {
8 const text = typeHint.trim();136 const text = typeHint.trim();
9 if (!text.startsWith("{") || !text.endsWith("}")) return [];137 if (!text.startsWith("{") || !text.endsWith("}")) return [];
@@ -70,6 +198,30 @@ export function compactArgKeysHint(keys: string[]): string { · 60 unmodified lines
70 198
71export function compactArgTypeHint(argsType: string): string {199export function compactArgTypeHint(argsType: string): string {
72 if (argsType === "{}") return "{}";200 if (argsType === "{}") return "{}";
201 const normalized = argsType.replace(/\s+/g, " ").trim();
202
203 const flattenedIntersection = normalizeIntersectionObjectHint(normalized, { maxLength: 160 });
204 if (flattenedIntersection) {
205 return flattenedIntersection;
206 }
207
208 const inlineFriendlyObject = normalized.startsWith("{")
209 && normalized.endsWith("}")
210 && normalized.length <= 100
211 && !normalized.includes("&");
212 if (inlineFriendlyObject) {
213 return normalizeFlatObjectHint(normalized) ?? normalized;
214 }
215
216 const inlineObjectWithSimpleUnions = normalized.startsWith("{")
217 && normalized.endsWith("}")
218 && normalized.length <= 220
219 && !normalized.includes("&")
220 && !normalized.slice(1, -1).includes("{")
221 && !normalized.slice(1, -1).includes("}");
222 if (inlineObjectWithSimpleUnions) {
223 return normalizeFlatObjectHint(normalized) ?? normalized;
224 }
73 const keys = extractTopLevelTypeKeys(argsType);225 const keys = extractTopLevelTypeKeys(argsType);
74 if (keys.length > 0) {226 if (keys.length > 0) {
75 return compactArgKeysHint(keys);227 return compactArgKeysHint(keys);
@@ -77,11 +229,43 @@ export function compactArgTypeHint(argsType: string): string { · 1 unmodified line
77 return truncateInline(argsType, 120);229 return truncateInline(argsType, 120);
78}230}
79 231
232export function compactArgDisplayHint(argsType: string, argPreviewKeys: string[] = []): string {
233 const compactFromType = compactArgTypeHint(argsType);
234 if (compactFromType !== "{}" && compactFromType.includes(":")) {
235 const normalized = compactFromType.replace(/\s+/g, " ").trim();
236 if (!normalized.includes("...")) {
237 return compactFromType;
238 }
239 }
240
241 if (argPreviewKeys.length > 0) {
242 return compactArgKeysHint(argPreviewKeys);
243 }
244
245 return compactFromType;
246}
247
80export function compactReturnTypeHint(returnsType: string): string {248export function compactReturnTypeHint(returnsType: string): string {
81 const normalized = returnsType.replace(/\s+/g, " ").trim();249 const normalized = returnsType.replace(/\s+/g, " ").trim();
82 if (normalized.startsWith("{ data:") && normalized.includes("errors:")) {250 if (normalized.startsWith("{ data:") && normalized.includes("errors:")) {
83 return "{ data: ...; errors: unknown[] }";251 return "{ data: ...; errors: unknown[] }";
84 }252 }
253 const flattenedIntersection = normalizeIntersectionObjectHint(normalized, {
254 maxParts: 6,
255 maxKeys: 16,
256 maxLength: 520,
257 });
258 if (flattenedIntersection) {
259 return flattenedIntersection;
260 }
261 const inlineFriendlyObject = normalized.startsWith("{")
262 && normalized.endsWith("}")
263 && normalized.length <= 520
264 && !normalized.includes("|")
265 && !normalized.includes("&");
266 if (inlineFriendlyObject) {
267 return normalized;
268 }
85 if (normalized.endsWith("[]") && normalized.length > 90) {269 if (normalized.endsWith("[]") && normalized.length > 90) {
86 return "Array<...>";270 return "Array<...>";
87 }271 }

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t