History

Refresh custom tool connection schemas at read time

Rhys Sullivan committed 4 months ago

Commit 6e2010f1 parent 18fafe47 files · +184 −25Browse files

Unified · Split

7 files
  1. apps/host-selfhost/src/apps-wire.node.test.ts +69 −2
  2. packages/core/sdk/src/executor.ts +29 −8
  3. packages/core/sdk/src/plugin.ts +19 −0
  4. packages/plugins/apps/src/plugin/apps-plugin.test.ts +17 −1
  5. packages/plugins/apps/src/plugin/apps-plugin.ts +25 −14
  6. packages/react/src/components/json-schema-form.test.ts +8 −0
  7. packages/react/src/components/json-schema-form.tsx +17 −0
apps/host-selfhost/src/apps-wire.node.test.ts+69−2
@@ -426,7 +426,7 @@ const approvalFiles = (): Record<string, string> => ({ · 425 unmodified lines
426426 "tools/approval-bridge.ts": approvalBridgeSource,
427427 });
428428
429−const registerGitHubIntegration = async (emulator: Emulator, token: string): Promise<void> => {
429+const registerGitHubSpec = async (emulator: Emulator): Promise<void> => {
430430 const specResponse = await fetch(`${server.baseUrl}/api/openapi/specs`, {
431431 method: "POST",
432432 headers: jsonHeaders,
@@ -439,13 +439,18 @@ const registerGitHubIntegration = async (emulator: Emulator, token: string): Pro · 6 unmodified lines
439439 });
440440 expect([200, 409]).toContain(specResponse.status);
441441 await specResponse.text();
442+};
443+
444+const createGitHubConnection = async (token: string): Promise<string> => {
442445 const existing = await requestJson<readonly ConnectionRow[]>(
443446 "/api/connections?integration=github",
444447 {
445448 headers: jsonHeaders,
446449 },
447450 );
448− if (existing.some((connection) => connection.address === GITHUB_CONNECTION)) return;
451+ if (existing.some((connection) => connection.address === GITHUB_CONNECTION)) {
452+ return GITHUB_CONNECTION;
453+ }
449454 const created = await postJson<{ address: string }>("/api/connections", {
450455 owner: "user",
451456 name: "main",
@@ -454,6 +459,12 @@ const registerGitHubIntegration = async (emulator: Emulator, token: string): Pro · 2 unmodified lines
454459 value: token,
455460 });
456461 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);
457468 };
458469
459470 const sourceUrl = (repo = REPO_FULL_NAME): string => `https://github.com/${repo}`;
@@ -522,6 +533,62 @@ afterAll(async () => { · 62 unmodified lines
522533 await github?.close();
523534 });
524535
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+
525592 test("GitHub source sync publishes and invokes custom tools through self-host HTTP", async () => {
526593 const credential = await github.credentials.mint({
527594 type: "api-key",
packages/core/sdk/src/executor.ts+29−8
@@ -3089,6 +3089,30 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3088 unmodified lines
30893089 );
30903090 if (effective.action === "block") return null;
30913091
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+
30923116 const definitionRows = yield* core.findMany("definition", {
30933117 where: (b: AnyCb) =>
30943118 b.and(
@@ -3100,15 +3124,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
31003124 const defs = new Map<string, unknown>();
31013125 for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema));
31023126
3103− const referenced = collectReferencedDefinitions(
3104− [tool.inputSchema, tool.outputSchema],
3105− defs,
3106− );
3127+ const referenced = collectReferencedDefinitions([inputSchema, outputSchema], defs);
31073128 const preview = yield* Effect.tryPromise({
31083129 try: () =>
31093130 buildToolTypeScriptPreview({
3110− inputSchema: tool.inputSchema,
3111− outputSchema: tool.outputSchema,
3131+ inputSchema,
3132+ outputSchema,
31123133 defs,
31133134 }),
31143135 catch: (cause) =>
@@ -3120,8 +3141,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
31203141 address,
31213142 name: tool.name,
31223143 description: tool.description,
3123− inputSchema: tool.inputSchema,
3124− outputSchema: tool.outputSchema,
3144+ inputSchema,
3145+ outputSchema,
31253146 schemaDefinitions:
31263147 Object.keys(referenced).length > 0
31273148 ? (referenced as Record<string, unknown>)
packages/core/sdk/src/plugin.ts+19−0
@@ -334,6 +334,18 @@ export interface ResolveToolsResult { · 333 unmodified lines
334334 readonly incomplete?: boolean;
335335 }
336336
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+
337349 // ---------------------------------------------------------------------------
338350 // Resolved credential handed to `invokeTool` so the plugin renders auth onto
339351 // the request (D11: "auth state derived into the auth-template format").
@@ -622,6 +634,13 @@ export interface PluginSpec< · 282 unmodified lines
622634 * for catalogs derived purely from stored state (specs, static config). */
623635 readonly remoteToolCatalog?: boolean;
624636
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+
625644 /** Invoke a dynamic tool. Called when the static-handler map doesn't have the
626645 * address. The plugin applies `input.credential` to the outbound request. */
627646 readonly invokeTool?: (input: InvokeToolInput<TStore>) => Effect.Effect<unknown, unknown>;
packages/plugins/apps/src/plugin/apps-plugin.test.ts+17−1
@@ -73,7 +73,23 @@ describe("appsPlugin custom-tools contract", () => { · 72 unmodified lines
7373 );
7474 const syncTool = resolved.tools.find((tool) => String(tool.name) === "deal-pipeline-sync");
7575 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 {
7793 properties: Record<string, { enum?: string[]; default?: string; description?: string }>;
7894 required?: string[];
7995 };
packages/plugins/apps/src/plugin/apps-plugin.ts+25−14
@@ -332,21 +332,35 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 331 unmodified lines
332332 const scope = yield* scopeFor(tenant, String(connection.name));
333333 const descriptor = yield* runtime.getDescriptor(tenant, scope);
334334 if (!descriptor) return { tools: [] } satisfies ResolveToolsResult;
335− const resolver =
336− makeResolver && ctx ? makeResolver({ ctx, scope, tool: "*" }) : runtime.deps.resolver;
337335 const tools: ToolDef[] = [];
338336 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));
346338 }
347339 return { tools } satisfies ResolveToolsResult;
348340 }),
349341
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+
350364 invokeTool: ({ ctx, toolRow, args, invokeOptions }: InvokeToolInput<AppsStoreShape>) =>
351365 Effect.gen(function* () {
352366 const tenant = tenantFor(ctx);
@@ -382,13 +396,10 @@ export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { · 29 unmodified lines
382396 };
383397 });
384398
385−const projectTool = (
386− descriptor: ToolDescriptor,
387− byRole: Readonly<Record<string, readonly ConnectionCandidate[]>>,
388−): ToolDef => ({
399+const projectTool = (descriptor: ToolDescriptor): ToolDef => ({
389400 name: ToolName.make(descriptor.name),
390401 description: descriptor.description,
391− inputSchema: projectInputSchema(descriptor.inputSchema, descriptor.integrations, byRole),
402+ inputSchema: descriptor.inputSchema,
392403 outputSchema: descriptor.outputSchema,
393404 annotations: {
394405 requiresApproval: descriptor.annotations?.destructive === true,
packages/react/src/components/json-schema-form.test.ts+8−0
@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; · 4 unmodified lines
55 import {
66 coerceNumber,
77 defaultForSchema,
8+ emptyEnumGuidance,
89 isRenderableObjectSchema,
910 missingRequiredFields,
1011 removeProperty,
@@ -108,6 +109,13 @@ describe("json-schema-form value transforms", () => { · 97 unmodified lines
108109 expect(defaultForSchema({ const: 7 }, root)).toBe(7);
109110 });
110111
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+
111119 it("isRenderableObjectSchema gates form-mode availability", () => {
112120 expect(isRenderableObjectSchema(SCHEMA)).toBe(true);
113121
packages/react/src/components/json-schema-form.tsx+17−0
@@ -95,6 +95,11 @@ const schemaDescription = (schema: JsonSchema): string | undefined => { · 94 unmodified lines
9595 return undefined;
9696 };
9797
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+
98103 const DEFAULT_BY_TYPE: Record<string, () => unknown> = {
99104 string: () => "",
100105 number: () => 0,
@@ -413,6 +418,18 @@ function FieldControl(props: { · 312 unmodified lines
413418 );
414419 }
415420
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+
416433 // enum — native select. Optional/unset gets a leading empty option.
417434 if (Array.isArray(schema.enum) && schema.enum.length > 0) {
418435 const options = schema.enum;