Unified · Split
| @@ -54,6 +54,7 @@ const baseProvenance = (pointer: string): ProvenanceRef[] => [{ · 53 unmodified lines | ||
| 54 | 54 | const createHttpCatalog = (input: { |
| 55 | 55 | collideIds?: boolean; |
| 56 | 56 | method?: string; |
| 57 | + noContentSuccess?: boolean; | |
| 57 | 58 | } = {}): CatalogV1 => { |
| 58 | 59 | const catalog = createEmptyCatalogV1(); |
| 59 | 60 | |
| @@ -327,16 +328,22 @@ const createHttpCatalog = (input: { · 267 unmodified lines | ||
| 327 | 328 | id: responseId, |
| 328 | 329 | kind: "response", |
| 329 | 330 | docs: { |
| 330 | − summary: "Updated event", | |
| 331 | + summary: input.noContentSuccess ? "No content" : "Updated event", | |
| 331 | 332 | }, |
| 332 | − contents: [ | |
| 333 | − { | |
| 334 | − mediaType: "application/json", | |
| 335 | − shapeId: resultShapeId, | |
| 336 | − }, | |
| 337 | − ], | |
| 333 | + ...(input.noContentSuccess | |
| 334 | + ? {} | |
| 335 | + : { | |
| 336 | + contents: [ | |
| 337 | + { | |
| 338 | + mediaType: "application/json", | |
| 339 | + shapeId: resultShapeId, | |
| 340 | + }, | |
| 341 | + ], | |
| 342 | + }), | |
| 338 | 343 | synthetic: false, |
| 339 | − provenance: baseProvenance("#/paths/~1events/responses/200"), | |
| 344 | + provenance: baseProvenance( | |
| 345 | + input.noContentSuccess ? "#/paths/~1events/responses/204" : "#/paths/~1events/responses/200", | |
| 346 | + ), | |
| 340 | 347 | }); |
| 341 | 348 | |
| 342 | 349 | put(catalog.responseSets as Record<typeof responseSetId, CatalogV1["responseSets"][typeof responseSetId]>, responseSetId, { |
| @@ -345,7 +352,7 @@ const createHttpCatalog = (input: { · 2 unmodified lines | ||
| 345 | 352 | { |
| 346 | 353 | match: { |
| 347 | 354 | kind: "exact", |
| 348 | − status: 200, | |
| 355 | + status: input.noContentSuccess ? 204 : 200, | |
| 349 | 356 | }, |
| 350 | 357 | responseId, |
| 351 | 358 | traits: ["success"], |
| @@ -805,6 +812,27 @@ describe("IR catalog", () => { · 453 unmodified lines | ||
| 805 | 812 | expect(searchDoc.tags).toEqual(["calendar", "events"]); |
| 806 | 813 | }); |
| 807 | 814 | |
| 815 | + it("projects no-content HTTP successes as null data", () => { | |
| 816 | + const projected = projectCatalogForAgentSdk({ | |
| 817 | + catalog: createHttpCatalog({ noContentSuccess: true }), | |
| 818 | + }); | |
| 819 | + | |
| 820 | + const descriptor = projected.toolDescriptors[CapabilityIdSchema.make("cap_events_update")]; | |
| 821 | + const resultShape = descriptor.resultShapeId | |
| 822 | + ? projected.catalog.symbols[descriptor.resultShapeId] | |
| 823 | + : undefined; | |
| 824 | + if (resultShape?.kind !== "shape" || resultShape.node.type !== "object") { | |
| 825 | + throw new Error("Expected projected HTTP result envelope"); | |
| 826 | + } | |
| 827 | + | |
| 828 | + const dataShape = projected.catalog.symbols[resultShape.node.fields.data.shapeId]; | |
| 829 | + if (dataShape?.kind !== "shape" || dataShape.node.type !== "const") { | |
| 830 | + throw new Error("Expected no-content success data to project as null"); | |
| 831 | + } | |
| 832 | + | |
| 833 | + expect(dataShape.node.value).toBeNull(); | |
| 834 | + }); | |
| 835 | + | |
| 808 | 836 | it("groups colliding HTTP parameter names into path/query containers", () => { |
| 809 | 837 | const projected = projectCatalogForAgentSdk({ |
| 810 | 838 | catalog: createHttpCatalog({ collideIds: true }), |
| @@ -538,15 +538,30 @@ const projectResultShapeFromResponses = ( · 537 unmodified lines | ||
| 538 | 538 | catalog: CatalogV1, |
| 539 | 539 | capability: Capability, |
| 540 | 540 | responseSet: ResponseSet, |
| 541 | −): ShapeSymbolId | undefined => | |
| 542 | − contentShapeIdFromResponseEntries( | |
| 541 | +): ShapeSymbolId | undefined => { | |
| 542 | + const successEntries = responseVariantEntries(catalog, responseSet) | |
| 543 | + .filter(({ variant }) => isSuccessStatusMatch(variant.match)); | |
| 544 | + const contentShapeId = contentShapeIdFromResponseEntries( | |
| 543 | 545 | catalog, |
| 544 | 546 | capability, |
| 545 | 547 | `responseSet:${responseSet.id}`, |
| 546 | 548 | `${capability.surface.title ?? capability.id} result`, |
| 547 | − responseVariantEntries(catalog, responseSet).filter(({ variant }) => isSuccessStatusMatch(variant.match)), | |
| 549 | + successEntries, | |
| 548 | 550 | ); |
| 549 | 551 | |
| 552 | + if (contentShapeId) { | |
| 553 | + return contentShapeId; | |
| 554 | + } | |
| 555 | + | |
| 556 | + return successEntries.length > 0 | |
| 557 | + ? constNullShape( | |
| 558 | + catalog, | |
| 559 | + capability, | |
| 560 | + `responseSet:${responseSet.id}:success:null`, | |
| 561 | + ) | |
| 562 | + : undefined; | |
| 563 | +}; | |
| 564 | + | |
| 550 | 565 | const projectErrorShapeFromResponses = ( |
| 551 | 566 | catalog: CatalogV1, |
| 552 | 567 | capability: Capability, |
| @@ -622,6 +637,15 @@ const nullableShape = ( · 69 unmodified lines | ||
| 622 | 637 | baseShapeId: ShapeSymbolId; |
| 623 | 638 | }, |
| 624 | 639 | ): ShapeSymbolId => { |
| 640 | + const baseShape = catalog.symbols[input.baseShapeId]; | |
| 641 | + if ( | |
| 642 | + baseShape?.kind === "shape" | |
| 643 | + && baseShape.node.type === "const" | |
| 644 | + && baseShape.node.value === null | |
| 645 | + ) { | |
| 646 | + return input.baseShapeId; | |
| 647 | + } | |
| 648 | + | |
| 625 | 649 | const nullShapeId = constNullShape(catalog, capability, `${input.label}:null`); |
| 626 | 650 | if (input.baseShapeId === nullShapeId) { |
| 627 | 651 | return nullShapeId; |
| @@ -48,14 +48,65 @@ const vercelFixturePath = fileURLToPath( · 47 unmodified lines | ||
| 48 | 48 | new URL("../fixtures/vercel-openapi.json", import.meta.url), |
| 49 | 49 | ); |
| 50 | 50 | |
| 51 | −const makeSource = (): Source => ({ | |
| 52 | − id: "source_vercel_api", | |
| 51 | +const noContentSuccessOpenApiFixture = JSON.stringify({ | |
| 52 | + openapi: "3.1.0", | |
| 53 | + info: { | |
| 54 | + title: "GitHub REST API", | |
| 55 | + version: "1.0.0", | |
| 56 | + }, | |
| 57 | + servers: [ | |
| 58 | + { | |
| 59 | + url: "https://api.github.com", | |
| 60 | + }, | |
| 61 | + ], | |
| 62 | + paths: { | |
| 63 | + "/user/starred/{owner}/{repo}": { | |
| 64 | + get: { | |
| 65 | + operationId: "checkRepoIsStarredByAuthenticatedUser", | |
| 66 | + summary: "Check if a repository is starred by the authenticated user", | |
| 67 | + parameters: [ | |
| 68 | + { | |
| 69 | + name: "owner", | |
| 70 | + in: "path", | |
| 71 | + required: true, | |
| 72 | + schema: { | |
| 73 | + type: "string", | |
| 74 | + }, | |
| 75 | + }, | |
| 76 | + { | |
| 77 | + name: "repo", | |
| 78 | + in: "path", | |
| 79 | + required: true, | |
| 80 | + schema: { | |
| 81 | + type: "string", | |
| 82 | + }, | |
| 83 | + }, | |
| 84 | + ], | |
| 85 | + responses: { | |
| 86 | + "204": { | |
| 87 | + description: "Response if this repository is starred by you", | |
| 88 | + }, | |
| 89 | + "404": { | |
| 90 | + description: "Not Found if this repository is not starred by you", | |
| 91 | + }, | |
| 92 | + }, | |
| 93 | + }, | |
| 94 | + }, | |
| 95 | + }, | |
| 96 | +}); | |
| 97 | + | |
| 98 | +const makeSource = (input: { | |
| 99 | + id?: string; | |
| 100 | + name?: string; | |
| 101 | + namespace?: string; | |
| 102 | +} = {}): Source => ({ | |
| 103 | + id: input.id ?? "source_vercel_api", | |
| 53 | 104 | scopeId: "scope_test", |
| 54 | − name: "Vercel API", | |
| 105 | + name: input.name ?? "Vercel API", | |
| 55 | 106 | kind: "openapi", |
| 56 | 107 | status: "connected", |
| 57 | 108 | enabled: true, |
| 58 | − namespace: "vercel-api", | |
| 109 | + namespace: input.namespace ?? "vercel-api", | |
| 59 | 110 | createdAt: 0, |
| 60 | 111 | updatedAt: 0, |
| 61 | 112 | }); |
| @@ -167,4 +218,76 @@ describe("openapi type contracts", () => { · 105 unmodified lines | ||
| 167 | 218 | expect(declaration).not.toContain("email: unknown;"); |
| 168 | 219 | expect(declaration).not.toContain("country: unknown;"); |
| 169 | 220 | }).pipe(Effect.provide(NodeFileSystem.layer))); |
| 221 | + | |
| 222 | + it.effect("renders null success data for bodyless OpenAPI responses", () => | |
| 223 | + Effect.gen(function* () { | |
| 224 | + const source = makeSource({ | |
| 225 | + id: "source_github_v3_rest_api", | |
| 226 | + name: "GitHub REST API", | |
| 227 | + namespace: "github-v3-rest-api", | |
| 228 | + }); | |
| 229 | + const manifest = yield* extractOpenApiManifest( | |
| 230 | + source.name, | |
| 231 | + noContentSuccessOpenApiFixture, | |
| 232 | + ); | |
| 233 | + const operations = compileOpenApiToolDefinitions(manifest).map( | |
| 234 | + openApiCatalogOperationFromDefinition, | |
| 235 | + ); | |
| 236 | + const fragment = createOpenApiCatalogFragment({ | |
| 237 | + source, | |
| 238 | + documents: [ | |
| 239 | + { | |
| 240 | + documentKind: "openapi", | |
| 241 | + documentKey: "https://example.com/github-rest-openapi.json", | |
| 242 | + fetchedAt: 0, | |
| 243 | + contentText: noContentSuccessOpenApiFixture, | |
| 244 | + }, | |
| 245 | + ], | |
| 246 | + operations, | |
| 247 | + }); | |
| 248 | + const snapshot = snapshotFromSourceCatalogSyncResult( | |
| 249 | + createSourceCatalogSyncResult({ | |
| 250 | + fragment, | |
| 251 | + importMetadata: createCatalogImportMetadata({ | |
| 252 | + source, | |
| 253 | + pluginKey: "openapi", | |
| 254 | + }), | |
| 255 | + sourceHash: manifest.sourceHash, | |
| 256 | + }), | |
| 257 | + ); | |
| 258 | + const projected = projectCatalogForAgentSdk({ | |
| 259 | + catalog: snapshot.catalog, | |
| 260 | + }); | |
| 261 | + const revision = makeRevision(); | |
| 262 | + const loadedCatalog: LoadedSourceCatalog = { | |
| 263 | + source, | |
| 264 | + sourceRecord: makeSourceRecord(source, revision), | |
| 265 | + revision, | |
| 266 | + snapshot, | |
| 267 | + catalog: snapshot.catalog, | |
| 268 | + projected, | |
| 269 | + typeProjector: createCatalogTypeProjector({ | |
| 270 | + catalog: projected.catalog, | |
| 271 | + roots: projectedCatalogTypeRoots(projected), | |
| 272 | + }), | |
| 273 | + importMetadata: snapshot.import, | |
| 274 | + }; | |
| 275 | + | |
| 276 | + const tools = yield* expandCatalogTools({ | |
| 277 | + catalogs: [loadedCatalog], | |
| 278 | + includeSchemas: true, | |
| 279 | + includeTypePreviews: true, | |
| 280 | + }); | |
| 281 | + const tool = tools.find((candidate) => | |
| 282 | + candidate.path.endsWith(".checkRepoIsStarredByAuthenticatedUser") | |
| 283 | + ); | |
| 284 | + | |
| 285 | + expect(tool).toBeDefined(); | |
| 286 | + | |
| 287 | + const contract = yield* buildLoadedSourceCatalogToolContract(tool!); | |
| 288 | + | |
| 289 | + expect(contract.output.typeDeclaration).toContain("data: null;"); | |
| 290 | + expect(contract.output.typeDeclaration).not.toContain("data: unknown"); | |
| 291 | + expect(contract.output.typePreview).toContain("data: null"); | |
| 292 | + }).pipe(Effect.provide(NodeFileSystem.layer))); | |
| 170 | 293 | }); |