Unified · Split
| @@ -426,7 +426,7 @@ const approvalFiles = (): Record<string, string> => ({ · 425 unmodified lines | |||
| 426 | "tools/approval-bridge.ts": approvalBridgeSource, | 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 | const specResponse = await fetch(`${server.baseUrl}/api/openapi/specs`, { | 430 | const specResponse = await fetch(`${server.baseUrl}/api/openapi/specs`, { |
| 431 | method: "POST", | 431 | method: "POST", |
| 432 | headers: jsonHeaders, | 432 | headers: jsonHeaders, |
| @@ -439,13 +439,18 @@ const registerGitHubIntegration = async (emulator: Emulator, token: string): Pro · 6 unmodified lines | |||
| 439 | }); | 439 | }); |
| 440 | expect([200, 409]).toContain(specResponse.status); | 440 | expect([200, 409]).toContain(specResponse.status); |
| 441 | await specResponse.text(); | 441 | await specResponse.text(); |
| 442 | }; | ||
| 443 | |||
| 444 | const createGitHubConnection = async (token: string): Promise<string> => { | ||
| 442 | const existing = await requestJson<readonly ConnectionRow[]>( | 445 | const existing = await requestJson<readonly ConnectionRow[]>( |
| 443 | "/api/connections?integration=github", | 446 | "/api/connections?integration=github", |
| 444 | { | 447 | { |
| 445 | headers: jsonHeaders, | 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 | const created = await postJson<{ address: string }>("/api/connections", { | 454 | const created = await postJson<{ address: string }>("/api/connections", { |
| 450 | owner: "user", | 455 | owner: "user", |
| 451 | name: "main", | 456 | name: "main", |
| @@ -454,6 +459,12 @@ const registerGitHubIntegration = async (emulator: Emulator, token: string): Pro · 2 unmodified lines | |||
| 454 | value: token, | 459 | value: token, |
| 455 | }); | 460 | }); |
| 456 | expect(created.address).toBe(GITHUB_CONNECTION); | 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 | const sourceUrl = (repo = REPO_FULL_NAME): string => `https://github.com/${repo}`; | 470 | const sourceUrl = (repo = REPO_FULL_NAME): string => `https://github.com/${repo}`; |
| @@ -522,6 +533,62 @@ afterAll(async () => { · 62 unmodified lines | |||
| 522 | await github?.close(); | 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 | test("GitHub source sync publishes and invokes custom tools through self-host HTTP", async () => { | 592 | test("GitHub source sync publishes and invokes custom tools through self-host HTTP", async () => { |
| 526 | const credential = await github.credentials.mint({ | 593 | const credential = await github.credentials.mint({ |
| 527 | type: "api-key", | 594 | type: "api-key", |
| @@ -3089,6 +3089,30 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3088 unmodified lines | |||
| 3089 | ); | 3089 | ); |
| 3090 | if (effective.action === "block") return null; | 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 | const definitionRows = yield* core.findMany("definition", { | 3116 | const definitionRows = yield* core.findMany("definition", { |
| 3093 | where: (b: AnyCb) => | 3117 | where: (b: AnyCb) => |
| 3094 | b.and( | 3118 | b.and( |
| @@ -3100,15 +3124,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines | |||
| 3100 | const defs = new Map<string, unknown>(); | 3124 | const defs = new Map<string, unknown>(); |
| 3101 | for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema)); | 3125 | for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema)); |
| 3102 | 3126 | ||
| 3103 | const referenced = collectReferencedDefinitions( | 3127 | const referenced = collectReferencedDefinitions([inputSchema, outputSchema], defs); |
| 3104 | [tool.inputSchema, tool.outputSchema], | ||
| 3105 | defs, | ||
| 3106 | ); | ||
| 3107 | const preview = yield* Effect.tryPromise({ | 3128 | const preview = yield* Effect.tryPromise({ |
| 3108 | try: () => | 3129 | try: () => |
| 3109 | buildToolTypeScriptPreview({ | 3130 | buildToolTypeScriptPreview({ |
| 3110 | inputSchema: tool.inputSchema, | 3131 | inputSchema, |
| 3111 | outputSchema: tool.outputSchema, | 3132 | outputSchema, |
| 3112 | defs, | 3133 | defs, |
| 3113 | }), | 3134 | }), |
| 3114 | catch: (cause) => | 3135 | catch: (cause) => |
| @@ -3120,8 +3141,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines | |||
| 3120 | address, | 3141 | address, |
| 3121 | name: tool.name, | 3142 | name: tool.name, |
| 3122 | description: tool.description, | 3143 | description: tool.description, |
| 3123 | inputSchema: tool.inputSchema, | 3144 | inputSchema, |
| 3124 | outputSchema: tool.outputSchema, | 3145 | outputSchema, |
| 3125 | schemaDefinitions: | 3146 | schemaDefinitions: |
| 3126 | Object.keys(referenced).length > 0 | 3147 | Object.keys(referenced).length > 0 |
| 3127 | ? (referenced as Record<string, unknown>) | 3148 | ? (referenced as Record<string, unknown>) |
| @@ -334,6 +334,18 @@ export interface ResolveToolsResult { · 333 unmodified lines | |||
| 334 | readonly incomplete?: boolean; | 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 | // Resolved credential handed to `invokeTool` so the plugin renders auth onto | 350 | // Resolved credential handed to `invokeTool` so the plugin renders auth onto |
| 339 | // the request (D11: "auth state derived into the auth-template format"). | 351 | // the request (D11: "auth state derived into the auth-template format"). |
| @@ -622,6 +634,13 @@ export interface PluginSpec< · 282 unmodified lines | |||
| 622 | * for catalogs derived purely from stored state (specs, static config). */ | 634 | * for catalogs derived purely from stored state (specs, static config). */ |
| 623 | readonly remoteToolCatalog?: boolean; | 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 | /** Invoke a dynamic tool. Called when the static-handler map doesn't have the | 644 | /** Invoke a dynamic tool. Called when the static-handler map doesn't have the |
| 626 | * address. The plugin applies `input.credential` to the outbound request. */ | 645 | * address. The plugin applies `input.credential` to the outbound request. */ |
| 627 | readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>; | 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 | const syncTool = resolved.tools.find((tool) => String(tool.name) === "deal-pipeline-sync"); | 74 | const syncTool = resolved.tools.find((tool) => String(tool.name) === "deal-pipeline-sync"); |
| 75 | expect(syncTool).toBeTruthy(); | 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 | properties: Record<string, { enum?: string[]; default?: string; description?: string }>; | 93 | properties: Record<string, { enum?: string[]; default?: string; description?: string }>; |
| 78 | required?: string[]; | 94 | required?: string[]; |
| 79 | }; | 95 | }; |
| @@ -332,21 +332,35 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 331 unmodified lines | |||
| 332 | const scope = yield* scopeFor(tenant, String(connection.name)); | 332 | const scope = yield* scopeFor(tenant, String(connection.name)); |
| 333 | const descriptor = yield* runtime.getDescriptor(tenant, scope); | 333 | const descriptor = yield* runtime.getDescriptor(tenant, scope); |
| 334 | if (!descriptor) return { tools: [] } satisfies ResolveToolsResult; | 334 | if (!descriptor) return { tools: [] } satisfies ResolveToolsResult; |
| 335 | const resolver = | ||
| 336 | makeResolver && ctx ? makeResolver({ ctx, scope, tool: "*" }) : runtime.deps.resolver; | ||
| 337 | const tools: ToolDef[] = []; | 335 | const tools: ToolDef[] = []; |
| 338 | for (const t of descriptor.tools) { | 336 | for (const t of descriptor.tools) { |
| 339 | const byRole: Record<string, readonly ConnectionCandidate[]> = {}; | 337 | tools.push(projectTool(t)); |
| 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)); | ||
| 346 | } | 338 | } |
| 347 | return { tools } satisfies ResolveToolsResult; | 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 | invokeTool: ({ ctx, toolRow, args, invokeOptions }: InvokeToolInput<AppsStoreShape>) => | 364 | invokeTool: ({ ctx, toolRow, args, invokeOptions }: InvokeToolInput<AppsStoreShape>) => |
| 351 | Effect.gen(function* () { | 365 | Effect.gen(function* () { |
| 352 | const tenant = tenantFor(ctx); | 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 = ( | 399 | const projectTool = (descriptor: ToolDescriptor): ToolDef => ({ |
| 386 | descriptor: ToolDescriptor, | ||
| 387 | byRole: Readonly<Record<string, readonly ConnectionCandidate[]>>, | ||
| 388 | ): ToolDef => ({ | ||
| 389 | name: ToolName.make(descriptor.name), | 400 | name: ToolName.make(descriptor.name), |
| 390 | description: descriptor.description, | 401 | description: descriptor.description, |
| 391 | inputSchema: projectInputSchema(descriptor.inputSchema, descriptor.integrations, byRole), | 402 | inputSchema: descriptor.inputSchema, |
| 392 | outputSchema: descriptor.outputSchema, | 403 | outputSchema: descriptor.outputSchema, |
| 393 | annotations: { | 404 | annotations: { |
| 394 | requiresApproval: descriptor.annotations?.destructive === true, | 405 | requiresApproval: descriptor.annotations?.destructive === true, |
| @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; · 4 unmodified lines | |||
| 5 | import { | 5 | import { |
| 6 | coerceNumber, | 6 | coerceNumber, |
| 7 | defaultForSchema, | 7 | defaultForSchema, |
| 8 | emptyEnumGuidance, | ||
| 8 | isRenderableObjectSchema, | 9 | isRenderableObjectSchema, |
| 9 | missingRequiredFields, | 10 | missingRequiredFields, |
| 10 | removeProperty, | 11 | removeProperty, |
| @@ -108,6 +109,13 @@ describe("json-schema-form value transforms", () => { · 97 unmodified lines | |||
| 108 | expect(defaultForSchema({ const: 7 }, root)).toBe(7); | 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 | it("isRenderableObjectSchema gates form-mode availability", () => { | 119 | it("isRenderableObjectSchema gates form-mode availability", () => { |
| 112 | expect(isRenderableObjectSchema(SCHEMA)).toBe(true); | 120 | expect(isRenderableObjectSchema(SCHEMA)).toBe(true); |
| 113 | 121 | ||
| @@ -95,6 +95,11 @@ const schemaDescription = (schema: JsonSchema): string | undefined => { · 94 unmodified lines | |||
| 95 | return undefined; | 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 | const DEFAULT_BY_TYPE: Record<string, () => unknown> = { | 103 | const DEFAULT_BY_TYPE: Record<string, () => unknown> = { |
| 99 | string: () => "", | 104 | string: () => "", |
| 100 | number: () => 0, | 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 | // enum — native select. Optional/unset gets a leading empty option. | 433 | // enum — native select. Optional/unset gets a leading empty option. |
| 417 | if (Array.isArray(schema.enum) && schema.enum.length > 0) { | 434 | if (Array.isArray(schema.enum) && schema.enum.length > 0) { |
| 418 | const options = schema.enum; | 435 | const options = schema.enum; |