Unified · Split
| @@ -426,7 +426,7 @@ const approvalFiles = (): Record<string, string> => ({ · 425 unmodified lines | ||
| 426 | 426 | "tools/approval-bridge.ts": approvalBridgeSource, |
| 427 | 427 | }); |
| 428 | 428 | |
| 429 | −const registerGitHubIntegration = async (emulator: Emulator, token: string): Promise<void> => { | |
| 429 | +const registerGitHubSpec = async (emulator: Emulator): Promise<void> => { | |
| 430 | 430 | const specResponse = await fetch(`${server.baseUrl}/api/openapi/specs`, { |
| 431 | 431 | method: "POST", |
| 432 | 432 | headers: jsonHeaders, |
| @@ -439,13 +439,18 @@ const registerGitHubIntegration = async (emulator: Emulator, token: string): Pro · 6 unmodified lines | ||
| 439 | 439 | }); |
| 440 | 440 | expect([200, 409]).toContain(specResponse.status); |
| 441 | 441 | await specResponse.text(); |
| 442 | +}; | |
| 443 | + | |
| 444 | +const createGitHubConnection = async (token: string): Promise<string> => { | |
| 442 | 445 | const existing = await requestJson<readonly ConnectionRow[]>( |
| 443 | 446 | "/api/connections?integration=github", |
| 444 | 447 | { |
| 445 | 448 | headers: jsonHeaders, |
| 446 | 449 | }, |
| 447 | 450 | ); |
| 448 | − if (existing.some((connection) => connection.address === GITHUB_CONNECTION)) return; | |
| 451 | + if (existing.some((connection) => connection.address === GITHUB_CONNECTION)) { | |
| 452 | + return GITHUB_CONNECTION; | |
| 453 | + } | |
| 449 | 454 | const created = await postJson<{ address: string }>("/api/connections", { |
| 450 | 455 | owner: "user", |
| 451 | 456 | name: "main", |
| @@ -454,6 +459,12 @@ const registerGitHubIntegration = async (emulator: Emulator, token: string): Pro · 2 unmodified lines | ||
| 454 | 459 | value: token, |
| 455 | 460 | }); |
| 456 | 461 | expect(created.address).toBe(GITHUB_CONNECTION); |
| 462 | + return created.address; | |
| 463 | +}; | |
| 464 | + | |
| 465 | +const registerGitHubIntegration = async (emulator: Emulator, token: string): Promise<void> => { | |
| 466 | + await registerGitHubSpec(emulator); | |
| 467 | + await createGitHubConnection(token); | |
| 457 | 468 | }; |
| 458 | 469 | |
| 459 | 470 | const sourceUrl = (repo = REPO_FULL_NAME): string => `https://github.com/${repo}`; |
| @@ -522,6 +533,62 @@ afterAll(async () => { · 62 unmodified lines | ||
| 522 | 533 | await github?.close(); |
| 523 | 534 | }); |
| 524 | 535 | |
| 536 | +test("custom tool connection schema updates when connections change after sync", async () => { | |
| 537 | + const credential = await github.credentials.mint({ | |
| 538 | + type: "api-key", | |
| 539 | + login: OWNER, | |
| 540 | + scopes: ["repo", "user"], | |
| 541 | + }); | |
| 542 | + const token = credential.token; | |
| 543 | + expect(token).toBeTruthy(); | |
| 544 | + await registerGitHubSpec(github); | |
| 545 | + const initialConnections = await requestJson<readonly ConnectionRow[]>( | |
| 546 | + "/api/connections?integration=github", | |
| 547 | + { headers: jsonHeaders }, | |
| 548 | + ); | |
| 549 | + expect(initialConnections).toEqual([]); | |
| 550 | + await putRepoFiles(github, token!, initialFiles()); | |
| 551 | + | |
| 552 | + const published = await syncSource(); | |
| 553 | + expect(published.status).toBe("published"); | |
| 554 | + | |
| 555 | + const listed = await listAppTools(); | |
| 556 | + const dealSync = listed.find((tool) => tool.name === "deal-pipeline-sync"); | |
| 557 | + expect(dealSync).toBeTruthy(); | |
| 558 | + const beforeConnection = await requestJson<{ | |
| 559 | + inputSchema: { properties?: Record<string, unknown>; required?: readonly string[] }; | |
| 560 | + }>(`/api/tools/schema?address=${encodeURIComponent(dealSync!.address)}`, { | |
| 561 | + headers: jsonHeaders, | |
| 562 | + }); | |
| 563 | + expect(beforeConnection.inputSchema.properties?.github).toMatchObject({ | |
| 564 | + type: "string", | |
| 565 | + enum: [], | |
| 566 | + }); | |
| 567 | + expect(beforeConnection.inputSchema.required ?? []).toContain("github"); | |
| 568 | + | |
| 569 | + await createGitHubConnection(token!); | |
| 570 | + const afterConnection = await requestJson<{ | |
| 571 | + inputSchema: { properties?: Record<string, unknown>; required?: readonly string[] }; | |
| 572 | + }>(`/api/tools/schema?address=${encodeURIComponent(dealSync!.address)}`, { | |
| 573 | + headers: jsonHeaders, | |
| 574 | + }); | |
| 575 | + expect(afterConnection.inputSchema.properties?.github).toMatchObject({ | |
| 576 | + type: "string", | |
| 577 | + enum: [GITHUB_CONNECTION], | |
| 578 | + default: GITHUB_CONNECTION, | |
| 579 | + }); | |
| 580 | + expect(afterConnection.inputSchema.required ?? []).not.toContain("github"); | |
| 581 | + | |
| 582 | + const defaultInvoke = (await executeResult( | |
| 583 | + callAppToolCode("deal-pipeline-sync", { | |
| 584 | + owner: OWNER, | |
| 585 | + repo: REPO, | |
| 586 | + }), | |
| 587 | + )) as { result?: { ok?: boolean; data?: { synced?: number }; error?: unknown } }; | |
| 588 | + expect(defaultInvoke.result?.ok).toBe(true); | |
| 589 | + expect(defaultInvoke.result?.data?.synced).toBe(0); | |
| 590 | +}); | |
| 591 | + | |
| 525 | 592 | test("GitHub source sync publishes and invokes custom tools through self-host HTTP", async () => { |
| 526 | 593 | const credential = await github.credentials.mint({ |
| 527 | 594 | type: "api-key", |
| @@ -3089,6 +3089,30 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3088 unmodified lines | ||
| 3089 | 3089 | ); |
| 3090 | 3090 | if (effective.action === "block") return null; |
| 3091 | 3091 | |
| 3092 | + const runtime = runtimes.get(row.plugin_id); | |
| 3093 | + const projected = runtime?.plugin.projectToolSchema | |
| 3094 | + ? yield* runtime.plugin | |
| 3095 | + .projectToolSchema({ | |
| 3096 | + ctx: runtime.ctx, | |
| 3097 | + toolRow: row, | |
| 3098 | + inputSchema: tool.inputSchema, | |
| 3099 | + outputSchema: tool.outputSchema, | |
| 3100 | + }) | |
| 3101 | + .pipe( | |
| 3102 | + Effect.mapError((cause) => | |
| 3103 | + pluginStorageFailure(row.plugin_id, "projectToolSchema", cause), | |
| 3104 | + ), | |
| 3105 | + ) | |
| 3106 | + : null; | |
| 3107 | + const inputSchema = | |
| 3108 | + projected && Object.prototype.hasOwnProperty.call(projected, "inputSchema") | |
| 3109 | + ? projected.inputSchema | |
| 3110 | + : tool.inputSchema; | |
| 3111 | + const outputSchema = | |
| 3112 | + projected && Object.prototype.hasOwnProperty.call(projected, "outputSchema") | |
| 3113 | + ? projected.outputSchema | |
| 3114 | + : tool.outputSchema; | |
| 3115 | + | |
| 3092 | 3116 | const definitionRows = yield* core.findMany("definition", { |
| 3093 | 3117 | where: (b: AnyCb) => |
| 3094 | 3118 | b.and( |
| @@ -3100,15 +3124,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines | ||
| 3100 | 3124 | const defs = new Map<string, unknown>(); |
| 3101 | 3125 | for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema)); |
| 3102 | 3126 | |
| 3103 | − const referenced = collectReferencedDefinitions( | |
| 3104 | − [tool.inputSchema, tool.outputSchema], | |
| 3105 | − defs, | |
| 3106 | − ); | |
| 3127 | + const referenced = collectReferencedDefinitions([inputSchema, outputSchema], defs); | |
| 3107 | 3128 | const preview = yield* Effect.tryPromise({ |
| 3108 | 3129 | try: () => |
| 3109 | 3130 | buildToolTypeScriptPreview({ |
| 3110 | − inputSchema: tool.inputSchema, | |
| 3111 | − outputSchema: tool.outputSchema, | |
| 3131 | + inputSchema, | |
| 3132 | + outputSchema, | |
| 3112 | 3133 | defs, |
| 3113 | 3134 | }), |
| 3114 | 3135 | catch: (cause) => |
| @@ -3120,8 +3141,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines | ||
| 3120 | 3141 | address, |
| 3121 | 3142 | name: tool.name, |
| 3122 | 3143 | description: tool.description, |
| 3123 | − inputSchema: tool.inputSchema, | |
| 3124 | − outputSchema: tool.outputSchema, | |
| 3144 | + inputSchema, | |
| 3145 | + outputSchema, | |
| 3125 | 3146 | schemaDefinitions: |
| 3126 | 3147 | Object.keys(referenced).length > 0 |
| 3127 | 3148 | ? (referenced as Record<string, unknown>) |
| @@ -334,6 +334,18 @@ export interface ResolveToolsResult { · 333 unmodified lines | ||
| 334 | 334 | readonly incomplete?: boolean; |
| 335 | 335 | } |
| 336 | 336 | |
| 337 | +export interface ProjectToolSchemaInput<TStore = unknown> { | |
| 338 | + readonly ctx: PluginCtx<TStore>; | |
| 339 | + readonly toolRow: ToolInvocationRow; | |
| 340 | + readonly inputSchema?: unknown; | |
| 341 | + readonly outputSchema?: unknown; | |
| 342 | +} | |
| 343 | + | |
| 344 | +export interface ProjectToolSchemaResult { | |
| 345 | + readonly inputSchema?: unknown; | |
| 346 | + readonly outputSchema?: unknown; | |
| 347 | +} | |
| 348 | + | |
| 337 | 349 | // --------------------------------------------------------------------------- |
| 338 | 350 | // Resolved credential handed to `invokeTool` so the plugin renders auth onto |
| 339 | 351 | // the request (D11: "auth state derived into the auth-template format"). |
| @@ -622,6 +634,13 @@ export interface PluginSpec< · 282 unmodified lines | ||
| 622 | 634 | * for catalogs derived purely from stored state (specs, static config). */ |
| 623 | 635 | readonly remoteToolCatalog?: boolean; |
| 624 | 636 | |
| 637 | + /** Project a persisted tool row's stable schemas into the request-visible | |
| 638 | + * schema view. Use this only for volatile presentation data that should be | |
| 639 | + * current at read time, not persisted at catalog-refresh time. */ | |
| 640 | + readonly projectToolSchema?: ( | |
| 641 | + input: ProjectToolSchemaInput<TStore>, | |
| 642 | + ) => Effect.Effect<ProjectToolSchemaResult, unknown>; | |
| 643 | + | |
| 625 | 644 | /** Invoke a dynamic tool. Called when the static-handler map doesn't have the |
| 626 | 645 | * address. The plugin applies `input.credential` to the outbound request. */ |
| 627 | 646 | readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>; |
| @@ -73,7 +73,23 @@ describe("appsPlugin custom-tools contract", () => { · 72 unmodified lines | ||
| 73 | 73 | ); |
| 74 | 74 | const syncTool = resolved.tools.find((tool) => String(tool.name) === "deal-pipeline-sync"); |
| 75 | 75 | expect(syncTool).toBeTruthy(); |
| 76 | − const inputSchema = syncTool!.inputSchema as { | |
| 76 | + const persistedInputSchema = syncTool!.inputSchema as { | |
| 77 | + properties: Record<string, unknown>; | |
| 78 | + }; | |
| 79 | + expect(persistedInputSchema.properties.crm).toBeUndefined(); | |
| 80 | + | |
| 81 | + const projected = await run( | |
| 82 | + plugin.projectToolSchema!({ | |
| 83 | + ctx: { owner: { tenant: "org" } }, | |
| 84 | + toolRow: { | |
| 85 | + name: "deal-pipeline-sync", | |
| 86 | + connection: appConnection, | |
| 87 | + }, | |
| 88 | + inputSchema: syncTool!.inputSchema, | |
| 89 | + outputSchema: syncTool!.outputSchema, | |
| 90 | + } as never), | |
| 91 | + ); | |
| 92 | + const inputSchema = projected.inputSchema as { | |
| 77 | 93 | properties: Record<string, { enum?: string[]; default?: string; description?: string }>; |
| 78 | 94 | required?: string[]; |
| 79 | 95 | }; |
| @@ -332,21 +332,35 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 331 unmodified lines | ||
| 332 | 332 | const scope = yield* scopeFor(tenant, String(connection.name)); |
| 333 | 333 | const descriptor = yield* runtime.getDescriptor(tenant, scope); |
| 334 | 334 | if (!descriptor) return { tools: [] } satisfies ResolveToolsResult; |
| 335 | − const resolver = | |
| 336 | − makeResolver && ctx ? makeResolver({ ctx, scope, tool: "*" }) : runtime.deps.resolver; | |
| 337 | 335 | const tools: ToolDef[] = []; |
| 338 | 336 | for (const t of descriptor.tools) { |
| 339 | − const byRole: Record<string, readonly ConnectionCandidate[]> = {}; | |
| 340 | − for (const [role, decl] of Object.entries(t.integrations)) { | |
| 341 | − byRole[role] = yield* resolver | |
| 342 | − .listConnections({ integration: decl.integration }) | |
| 343 | − .pipe(Effect.orElseSucceed(() => [])); | |
| 344 | − } | |
| 345 | − tools.push(projectTool(t, byRole)); | |
| 337 | + tools.push(projectTool(t)); | |
| 346 | 338 | } |
| 347 | 339 | return { tools } satisfies ResolveToolsResult; |
| 348 | 340 | }), |
| 349 | 341 | |
| 342 | + projectToolSchema: ({ ctx, toolRow, inputSchema, outputSchema }) => | |
| 343 | + Effect.gen(function* () { | |
| 344 | + const tenant = tenantFor(ctx); | |
| 345 | + const scope = yield* scopeFor(tenant, String(toolRow.connection)); | |
| 346 | + const descriptor = yield* runtime.getDescriptor(tenant, scope); | |
| 347 | + const toolDesc = descriptor?.tools.find((t) => t.name === toolRow.name); | |
| 348 | + if (!toolDesc) return { inputSchema, outputSchema }; | |
| 349 | + const resolver = makeResolver | |
| 350 | + ? makeResolver({ ctx, scope, tool: toolRow.name }) | |
| 351 | + : runtime.deps.resolver; | |
| 352 | + const byRole: Record<string, readonly ConnectionCandidate[]> = {}; | |
| 353 | + for (const [role, decl] of Object.entries(toolDesc.integrations)) { | |
| 354 | + byRole[role] = yield* resolver | |
| 355 | + .listConnections({ integration: decl.integration }) | |
| 356 | + .pipe(Effect.orElseSucceed(() => [])); | |
| 357 | + } | |
| 358 | + return { | |
| 359 | + inputSchema: projectInputSchema(toolDesc.inputSchema, toolDesc.integrations, byRole), | |
| 360 | + outputSchema: toolDesc.outputSchema, | |
| 361 | + }; | |
| 362 | + }), | |
| 363 | + | |
| 350 | 364 | invokeTool: ({ ctx, toolRow, args, invokeOptions }: InvokeToolInput<AppsStoreShape>) => |
| 351 | 365 | Effect.gen(function* () { |
| 352 | 366 | const tenant = tenantFor(ctx); |
| @@ -382,13 +396,10 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 29 unmodified lines | ||
| 382 | 396 | }; |
| 383 | 397 | }); |
| 384 | 398 | |
| 385 | −const projectTool = ( | |
| 386 | − descriptor: ToolDescriptor, | |
| 387 | − byRole: Readonly<Record<string, readonly ConnectionCandidate[]>>, | |
| 388 | −): ToolDef => ({ | |
| 399 | +const projectTool = (descriptor: ToolDescriptor): ToolDef => ({ | |
| 389 | 400 | name: ToolName.make(descriptor.name), |
| 390 | 401 | description: descriptor.description, |
| 391 | − inputSchema: projectInputSchema(descriptor.inputSchema, descriptor.integrations, byRole), | |
| 402 | + inputSchema: descriptor.inputSchema, | |
| 392 | 403 | outputSchema: descriptor.outputSchema, |
| 393 | 404 | annotations: { |
| 394 | 405 | requiresApproval: descriptor.annotations?.destructive === true, |
| @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; · 4 unmodified lines | ||
| 5 | 5 | import { |
| 6 | 6 | coerceNumber, |
| 7 | 7 | defaultForSchema, |
| 8 | + emptyEnumGuidance, | |
| 8 | 9 | isRenderableObjectSchema, |
| 9 | 10 | missingRequiredFields, |
| 10 | 11 | removeProperty, |
| @@ -108,6 +109,13 @@ describe("json-schema-form value transforms", () => { · 97 unmodified lines | ||
| 108 | 109 | expect(defaultForSchema({ const: 7 }, root)).toBe(7); |
| 109 | 110 | }); |
| 110 | 111 | |
| 112 | + it("empty enum guidance names connection integrations", () => { | |
| 113 | + expect(emptyEnumGuidance("Connection to use for github (github)")).toBe( | |
| 114 | + "Connect github first.", | |
| 115 | + ); | |
| 116 | + expect(emptyEnumGuidance("Choose a value")).toBe("No options available."); | |
| 117 | + }); | |
| 118 | + | |
| 111 | 119 | it("isRenderableObjectSchema gates form-mode availability", () => { |
| 112 | 120 | expect(isRenderableObjectSchema(SCHEMA)).toBe(true); |
| 113 | 121 | |
| @@ -95,6 +95,11 @@ const schemaDescription = (schema: JsonSchema): string | undefined => { · 94 unmodified lines | ||
| 95 | 95 | return undefined; |
| 96 | 96 | }; |
| 97 | 97 | |
| 98 | +export const emptyEnumGuidance = (description: string | undefined): string => { | |
| 99 | + const integration = description?.match(/^Connection to use for .+ \(([^)]+)\)$/)?.[1]; | |
| 100 | + return integration ? `Connect ${integration} first.` : "No options available."; | |
| 101 | +}; | |
| 102 | + | |
| 98 | 103 | const DEFAULT_BY_TYPE: Record<string, () => unknown> = { |
| 99 | 104 | string: () => "", |
| 100 | 105 | number: () => 0, |
| @@ -413,6 +418,18 @@ function FieldControl(props: { · 312 unmodified lines | ||
| 413 | 418 | ); |
| 414 | 419 | } |
| 415 | 420 | |
| 421 | + if (Array.isArray(schema.enum) && schema.enum.length === 0) { | |
| 422 | + const guidance = emptyEnumGuidance(schemaDescription(schema)); | |
| 423 | + return ( | |
| 424 | + <div className="space-y-1"> | |
| 425 | + <NativeSelect id={fieldId} className="w-full text-xs" value="" disabled={true}> | |
| 426 | + <NativeSelectOption value="">{guidance}</NativeSelectOption> | |
| 427 | + </NativeSelect> | |
| 428 | + <p className="text-[11px] leading-4 text-muted-foreground">{guidance}</p> | |
| 429 | + </div> | |
| 430 | + ); | |
| 431 | + } | |
| 432 | + | |
| 416 | 433 | // enum — native select. Optional/unset gets a leading empty option. |
| 417 | 434 | if (Array.isArray(schema.enum) && schema.enum.length > 0) { |
| 418 | 435 | const options = schema.enum; |