- 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.
Unified · Split
| @@ -20,8 +20,8 @@ export function ToolDetail({ · 19 unmodified lines | ||
| 20 | 20 | loading?: boolean; |
| 21 | 21 | }) { |
| 22 | 22 | const insetLeft = depth * 20 + 8 + 16 + 8; |
| 23 | − const argsType = tool.strictArgsType?.trim() || tool.argsType; | |
| 24 | − const returnsType = tool.strictReturnsType?.trim() || tool.returnsType; | |
| 23 | + const argsType = tool.argsType?.trim() || tool.strictArgsType; | |
| 24 | + const returnsType = tool.returnsType?.trim() || tool.strictReturnsType; | |
| 25 | 25 | const hasDetails = Boolean(tool.description || argsType || returnsType); |
| 26 | 26 | |
| 27 | 27 | return ( |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | import fs from "node:fs/promises"; |
| 4 | 4 | import os from "node:os"; |
| 5 | 5 | import path from "node:path"; |
| 6 | +import { checkBootstrapHealth } from "./packages/core/src/managed-runtime-bootstrap"; | |
| 6 | 7 | import { managedRuntimeDiagnostics, runManagedBackend, runManagedWeb } from "./packages/core/src/managed-runtime"; |
| 7 | 8 | |
| 8 | 9 | interface InstallPaths { |
| @@ -297,6 +298,18 @@ async function stopManagedServices(): Promise<{ backendStopped: boolean; webStop · 288 unmodified lines | ||
| 297 | 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 | 313 | await fs.rm(paths.backendPidFile, { force: true }); |
| 301 | 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 | 354 | const nodeInstalled = await pathExists(info.nodeBin); |
| 342 | 355 | const backendRunning = await checkHttp(`${info.convexUrl}/version`); |
| 343 | 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 | 366 | console.log(`Executor status: ${healthy ? "ready" : "needs attention"}`); |
| 347 | 367 | console.log(`Dashboard: http://127.0.0.1:${webPort} (${webRunning ? "running" : "not running"})`); |
| 348 | 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 | 380 | console.log(`MCP endpoint: ${info.convexSiteUrl}/mcp`); |
| 350 | 381 | |
| 351 | 382 | if (!backendRunning) { |
| @@ -354,6 +385,15 @@ async function runDoctor(args: string[]): Promise<number> { · 2 unmodified lines | ||
| 354 | 385 | if (!webRunning) { |
| 355 | 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 | 397 | if (!webInstalled || !nodeInstalled) { |
| 358 | 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 | 407 | console.log(` convex site: ${info.convexSiteUrl}`); |
| 368 | 408 | console.log(` node runtime: ${nodeInstalled ? info.nodeBin : "missing"}`); |
| 369 | 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 | 414 | console.log(` running: backend=${backendRunning ? "yes" : "no"} web=${webRunning ? "yes" : "no"}`); |
| 371 | 415 | console.log(` config: ${info.configPath}`); |
| 372 | 416 | } |
| @@ -293,8 +293,10 @@ start_background_services() { · 292 unmodified lines | ||
| 293 | 293 | |
| 294 | 294 | if wait_for_http "http://127.0.0.1:${BACKEND_PORT}/version" 2; then |
| 295 | 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 | 297 | elif [[ -f "$BACKEND_PID_FILE" ]]; then |
| 297 | 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 | 300 | else |
| 299 | 301 | nohup "$INSTALL_DIR/$APP" up >"$LOG_DIR/backend.log" 2>&1 & |
| 300 | 302 | echo "$!" > "$BACKEND_PID_FILE" |
| @@ -322,6 +324,37 @@ start_background_services() { · 21 unmodified lines | ||
| 322 | 324 | fi |
| 323 | 325 | } |
| 324 | 326 | |
| 327 | +wait_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 | + | |
| 325 | 358 | install_web_bundle |
| 326 | 359 | |
| 327 | 360 | if [[ "$NO_MODIFY_PATH" != "1" ]]; then |
| @@ -341,6 +374,7 @@ if [[ "$NO_MODIFY_PATH" != "1" ]]; then · 13 unmodified lines | ||
| 341 | 374 | fi |
| 342 | 375 | |
| 343 | 376 | start_background_services |
| 377 | +wait_for_executor_ready || true | |
| 344 | 378 | |
| 345 | 379 | echo "" |
| 346 | 380 | echo "Installed to: $INSTALL_DIR/$APP" |
| @@ -1,7 +1,5 @@ | ||
| 1 | −const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env; | |
| 2 | −const workosClientIdKey = ["WORKOS", "CLIENT", "ID"].join("_"); | |
| 3 | −const rawClientId = env?.[workosClientIdKey]?.trim(); | |
| 4 | −const clientId = rawClientId && rawClientId.length > 0 ? rawClientId : undefined; | |
| 1 | +const rawClientId = process.env.WORKOS_CLIENT_ID?.trim(); | |
| 2 | +const clientId = rawClientId && rawClientId !== "disabled" ? rawClientId : undefined; | |
| 5 | 3 | |
| 6 | 4 | const authConfig = clientId |
| 7 | 5 | ? { |
| @@ -1,90 +0,0 @@ | ||
| 1 | −import { expect, test } from "bun:test"; | |
| 2 | −import { convexTest } from "convex-test"; | |
| 3 | −import { internal } from "./_generated/api"; | |
| 4 | −import schema from "./schema"; | |
| 5 | − | |
| 6 | −function 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 | − | |
| 20 | −test("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 | − | |
| 62 | −test("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 | −}); | |
| @@ -20,7 +20,7 @@ import type { ToolSourceRecord } from "../../core/src/types"; · 19 unmodified lines | ||
| 20 | 20 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; |
| 21 | 21 | |
| 22 | 22 | /** Cache version - bump when PreparedOpenApiSpec shape changes. */ |
| 23 | −const TOOL_SOURCE_CACHE_VERSION = "v16"; | |
| 23 | +const TOOL_SOURCE_CACHE_VERSION = "v17"; | |
| 24 | 24 | |
| 25 | 25 | export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { |
| 26 | 26 | const parts = sources |
| @@ -122,6 +122,7 @@ async function writeBootstrapEnvFile(info: ManagedRuntimeInfo, adminKey: string) · 121 unmodified lines | ||
| 122 | 122 | const contents = [ |
| 123 | 123 | `CONVEX_SELF_HOSTED_URL=http://${info.config.hostInterface}:${info.config.backendPort}`, |
| 124 | 124 | `CONVEX_SELF_HOSTED_ADMIN_KEY=${adminKey}`, |
| 125 | + "WORKOS_CLIENT_ID=disabled", | |
| 125 | 126 | ].join("\n"); |
| 126 | 127 | await fs.writeFile(filePath, `${contents}\n`, "utf8"); |
| 127 | 128 | return filePath; |
| @@ -173,15 +174,16 @@ export async function waitForBackendReady(info: ManagedRuntimeInfo, timeoutMs = · 45 unmodified lines | ||
| 173 | 174 | throw new Error("Timed out waiting for local Convex backend to become ready."); |
| 174 | 175 | } |
| 175 | 176 | |
| 176 | −export async function ensureProjectBootstrapped(info: ManagedRuntimeInfo): Promise<void> { | |
| 177 | − if (Bun.env.EXECUTOR_SKIP_BOOTSTRAP === "1") { | |
| 178 | − return; | |
| 179 | − } | |
| 177 | +export type BootstrapHealth = { | |
| 178 | + state: "ready" | "no_project" | "missing_functions" | "check_failed"; | |
| 179 | + projectDir?: string; | |
| 180 | + detail?: string; | |
| 181 | +}; | |
| 180 | 182 | |
| 183 | +export async function checkBootstrapHealth(info: ManagedRuntimeInfo): Promise<BootstrapHealth> { | |
| 181 | 184 | const projectDir = await findProjectDir(); |
| 182 | 185 | if (!projectDir) { |
| 183 | − console.log("[executor] no local Convex project found; skipping function bootstrap"); | |
| 184 | − return; | |
| 186 | + return { state: "no_project" }; | |
| 185 | 187 | } |
| 186 | 188 | |
| 187 | 189 | await ensureConvexCliRuntime(info); |
| @@ -189,26 +191,83 @@ export async function ensureProjectBootstrapped(info: ManagedRuntimeInfo): Promi · 1 unmodified line | ||
| 189 | 191 | const envFilePath = await writeBootstrapEnvFile(info, adminKey); |
| 190 | 192 | |
| 191 | 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 | 209 | const check = await runManagedConvexCli(info, projectDir, ["run", "app:getClientConfig"], envFilePath, { |
| 193 | 210 | stdout: "pipe", |
| 194 | 211 | stderr: "pipe", |
| 195 | 212 | }); |
| 196 | 213 | |
| 197 | 214 | if (check.exitCode === 0) { |
| 198 | − console.log("[executor] Convex functions already bootstrapped"); | |
| 199 | − return; | |
| 215 | + return { state: "ready", projectDir }; | |
| 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 | + | |
| 228 | +export 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 | 259 | console.log("[executor] bootstrapping Convex functions to local backend"); |
| 203 | 260 | const deploy = await runManagedConvexCli( |
| 204 | 261 | info, |
| 205 | 262 | projectDir, |
| 206 | 263 | ["deploy", "--yes", "--typecheck", "disable", "--codegen", "disable"], |
| 207 | 264 | envFilePath, |
| 265 | + { stdout: "pipe", stderr: "pipe" }, | |
| 208 | 266 | ); |
| 209 | 267 | |
| 210 | 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 | 272 | } finally { |
| 214 | 273 | await fs.rm(path.dirname(envFilePath), { recursive: true, force: true }); |
| @@ -55,7 +55,7 @@ export function buildRunCodeDescription(tools?: ToolDescriptor[]): string { · 54 unmodified lines | ||
| 55 | 55 | ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}` |
| 56 | 56 | : ""; |
| 57 | 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 | 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 | 60 | const graphqlNote = hasGraphqlTools |
| 61 | 61 | ? "\n\nGraphQL tip: prefer `source.query.*` / `source.mutation.*` helper paths when available; GraphQL tools return `{ data, errors }`." |
| @@ -456,12 +456,58 @@ describe("buildOpenApiToolsFromPrepared", () => { · 455 unmodified lines | ||
| 456 | 456 | const tool = tools.find((candidate) => candidate.metadata?.operationId === "createRecord"); |
| 457 | 457 | expect(tool).toBeDefined(); |
| 458 | 458 | expect(tool?.metadata?.argPreviewKeys).toEqual(["domain", "type", "name", "value"]); |
| 459 | − expect(tool?.metadata?.displayArgsType).toContain("name: ..."); | |
| 460 | − expect(tool?.metadata?.displayArgsType).toContain("value: ..."); | |
| 459 | + expect(tool?.metadata?.displayArgsType).toContain("name: string"); | |
| 460 | + expect(tool?.metadata?.displayArgsType).toContain("value: string"); | |
| 461 | 461 | expect(tool?.metadata?.argsType).toContain("name"); |
| 462 | 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 | 511 | test("infers workspace auth from OpenAPI securitySchemes when source auth is unset", async () => { |
| 466 | 512 | const spec: Record<string, unknown> = { |
| 467 | 513 | openapi: "3.0.3", |
| @@ -8,6 +8,53 @@ · 7 unmodified lines | ||
| 8 | 8 | */ |
| 9 | 9 | import { test, expect, describe } from "bun:test"; |
| 10 | 10 | import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool-sources"; |
| 11 | +import { generateToolDeclarations, typecheckCode } from "./typechecker"; | |
| 12 | +import type { ToolDefinition, ToolDescriptor } from "./types"; | |
| 13 | + | |
| 14 | +function 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 | |
| 12 | 59 | interface SpecFixture { |
| 13 | 60 | name: string; |
| @@ -206,6 +253,38 @@ describe("real-world OpenAPI specs", () => { · 192 unmodified lines | ||
| 206 | 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 | 288 | test( |
| 210 | 289 | "github: create hosted runner for org has non-unknown return hint", |
| 211 | 290 | async () => { |
| @@ -378,4 +457,104 @@ describe("real-world OpenAPI specs", () => { · 166 unmodified lines | ||
| 378 | 457 | }, |
| 379 | 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 | }); |
| @@ -9,7 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | import { buildOpenApiToolPath } from "./openapi-tool-path"; |
| 10 | 10 | import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "./tool-source-auth"; |
| 11 | 11 | import { executeOpenApiRequest } from "./tool-source-execution"; |
| 12 | −import { compactArgKeysHint, compactArgTypeHint, compactReturnTypeHint } from "./type-hints"; | |
| 12 | +import { compactArgDisplayHint, compactReturnTypeHint } from "./type-hints"; | |
| 13 | 13 | import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "./tool-source-types"; |
| 14 | 14 | import type { ToolDefinition } from "./types"; |
| 15 | 15 | import { asRecord } from "./utils"; |
| @@ -135,9 +135,7 @@ export function buildOpenApiToolsFromPrepared( · 119 unmodified lines | ||
| 135 | 135 | |
| 136 | 136 | const { argsType, returnsType, argPreviewKeys } = buildOpenApiOperationTypeHints(operation, parameters); |
| 137 | 137 | |
| 138 | − const displayArgsType = argPreviewKeys.length > 0 | |
| 139 | − ? compactArgKeysHint(argPreviewKeys) | |
| 140 | − : compactArgTypeHint(argsType); | |
| 138 | + const displayArgsType = compactArgDisplayHint(argsType, argPreviewKeys); | |
| 141 | 139 | const displayReturnsType = compactReturnTypeHint(returnsType); |
| 142 | 140 | |
| 143 | 141 | const approval = config.overrides?.[operationIdRaw]?.approval |
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | import type { ToolDefinition } from "./types"; |
| 2 | 2 | import { |
| 3 | − compactArgKeysHint, | |
| 4 | − compactArgTypeHint, | |
| 3 | + compactArgDisplayHint, | |
| 5 | 4 | compactDescriptionLine, |
| 6 | 5 | compactReturnTypeHint, |
| 7 | 6 | } from "./type-hints"; |
| @@ -95,7 +94,7 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 87 unmodified lines | ||
| 95 | 94 | approval: entry.approval, |
| 96 | 95 | description: compact ? compactDescriptionLine(entry.description) : entry.description, |
| 97 | 96 | argsType: compact |
| 98 | − ? (entry.argPreviewKeys.length > 0 ? compactArgKeysHint(entry.argPreviewKeys) : compactArgTypeHint(entry.argsType)) | |
| 97 | + ? compactArgDisplayHint(entry.argsType, entry.argPreviewKeys) | |
| 99 | 98 | : entry.argsType, |
| 100 | 99 | returnsType: compact ? compactReturnTypeHint(entry.returnsType) : entry.returnsType, |
| 101 | 100 | signature: formatSignature(entry, depth, compact), |
| @@ -1,6 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | − compactArgKeysHint, | |
| 3 | − compactArgTypeHint, | |
| 2 | + compactArgDisplayHint, | |
| 4 | 3 | compactReturnTypeHint, |
| 5 | 4 | extractTopLevelTypeKeys, |
| 6 | 5 | } from "../type-hints"; |
| @@ -35,9 +34,7 @@ export function formatSignature(entry: DiscoverIndexEntry, depth: number, compac · 28 unmodified lines | ||
| 35 | 34 | return "(input: ...): Promise<...>"; |
| 36 | 35 | } |
| 37 | 36 | |
| 38 | − const args = entry.argPreviewKeys.length > 0 | |
| 39 | − ? compactArgKeysHint(entry.argPreviewKeys) | |
| 40 | − : compactArgTypeHint(entry.argsType); | |
| 37 | + const args = compactArgDisplayHint(entry.argsType, entry.argPreviewKeys); | |
| 41 | 38 | const returns = compactReturnTypeHint(entry.returnsType); |
| 42 | 39 | |
| 43 | 40 | if (depth === 1) { |
| @@ -19,6 +19,18 @@ test("compactArgTypeHint keeps only top-level previews", async () => { · 18 unmodified lines | ||
| 19 | 19 | expect(compact).toBe("{ parent: ...; title: ...; icon: ...; properties: ... }"); |
| 20 | 20 | }); |
| 21 | 21 | |
| 22 | +test("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 | + | |
| 29 | +test("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 | + | |
| 22 | 34 | test("compactArgKeysHint caps key list with ellipsis", async () => { |
| 23 | 35 | const compact = compactArgKeysHint(["a", "b", "c", "d", "e", "f", "g", "h"]); |
| 24 | 36 | expect(compact).toBe("{ a: ...; b: ...; c: ...; d: ...; e: ...; f: ...; ... }"); |
| @@ -29,6 +41,15 @@ test("compactReturnTypeHint collapses graphql envelopes", async () => { · 4 unmodified lines | ||
| 29 | 41 | expect(compact).toBe("{ data: ...; errors: unknown[] }"); |
| 30 | 42 | }); |
| 31 | 43 | |
| 44 | +test("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 | + | |
| 32 | 53 | test("compactDescriptionLine trims to a single concise line", async () => { |
| 33 | 54 | const compact = compactDescriptionLine("Create a database in Notion.\nThis endpoint supports parent/page context."); |
| 34 | 55 | expect(compact).toBe("Create a database in Notion."); |
| @@ -4,6 +4,134 @@ function truncateInline(value: string, maxLength: number): string { · 3 unmodified lines | ||
| 4 | 4 | return `${normalized.slice(0, Math.max(16, maxLength - 3)).trim()}...`; |
| 5 | 5 | } |
| 6 | 6 | |
| 7 | +function 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 | + | |
| 46 | +function 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 | + | |
| 62 | +function 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 | + | |
| 90 | +function 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 | + | |
| 7 | 135 | export function extractTopLevelTypeKeys(typeHint: string): string[] { |
| 8 | 136 | const text = typeHint.trim(); |
| 9 | 137 | if (!text.startsWith("{") || !text.endsWith("}")) return []; |
| @@ -70,6 +198,30 @@ export function compactArgKeysHint(keys: string[]): string { · 60 unmodified lines | ||
| 70 | 198 | |
| 71 | 199 | export function compactArgTypeHint(argsType: string): string { |
| 72 | 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 | 225 | const keys = extractTopLevelTypeKeys(argsType); |
| 74 | 226 | if (keys.length > 0) { |
| 75 | 227 | return compactArgKeysHint(keys); |
| @@ -77,11 +229,43 @@ export function compactArgTypeHint(argsType: string): string { · 1 unmodified line | ||
| 77 | 229 | return truncateInline(argsType, 120); |
| 78 | 230 | } |
| 79 | 231 | |
| 232 | +export 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 | + | |
| 80 | 248 | export function compactReturnTypeHint(returnsType: string): string { |
| 81 | 249 | const normalized = returnsType.replace(/\s+/g, " ").trim(); |
| 82 | 250 | if (normalized.startsWith("{ data:") && normalized.includes("errors:")) { |
| 83 | 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 | 269 | if (normalized.endsWith("[]") && normalized.length > 90) { |
| 86 | 270 | return "Array<...>"; |
| 87 | 271 | } |