From a8037d6fbfdc4b296644bffa677dac3b1202d788 Mon Sep 17 00:00:00 2001 From: David East Date: Fri, 25 Sep 2026 04:52:40 +0000 Subject: [PATCH] fix(sdk): resolve 0.4.0 premortem Tigers and reconcile PRs #363 and #368 --- RELEASING.md | 4 +- packages/sdk/generated/src/designsystem.ts | 6 + packages/sdk/generated/src/project.ts | 16 +- packages/sdk/generated/src/screen.ts | 26 +- .../sdk/generated/src/tool-definitions.ts | 9 + packages/sdk/generated/stitch-sdk.lock | 10 +- packages/sdk/package.json | 2 +- packages/sdk/src/client.ts | 35 ++- packages/sdk/src/entity-manager.ts | 7 +- packages/sdk/src/index.ts | 7 + packages/sdk/src/schema-repair.ts | 91 +++++- packages/sdk/src/singleton.ts | 14 +- .../test/unit/bridge-0.4.0-premortem.test.ts | 294 +++++++++++------- packages/sdk/test/unit/schema-repair.test.ts | 86 ++++- scripts/generate-sdk.ts | 3 + scripts/publish-readiness.ts | 7 +- .../codegen-snapshot.test.ts.snap | 12 + 17 files changed, 491 insertions(+), 138 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index aa97f61..a5aae20 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -29,8 +29,8 @@ bun run check:bundle # root-entry size budget + catalog-leak probe ## Dist-tag policy (semver safety) -- **Pre-1.0 / rc:** version is `1.0.0-rc.N` and `publishConfig.tag` is `next`. An rc does NOT satisfy a consumer's `^0.3`/`~0.3` range and `next` is not installed by default, so a breaking pre-release can never auto-upgrade existing `0.x` consumers. This is the current state. -- **GA:** flip the version to `1.0.0` and `publishConfig.tag` to `latest` ONLY after the open post-review majors land (see `V1_REVIEW_FIXES.md` Tranches 2–4) — several are wrong public types/behavior that are themselves breaking to fix after GA. +- **Stable / Bridge releases (`0.4.0`, `1.0.0`):** `publishConfig.tag` is `latest` so `npm install @google/stitch-sdk` installs the active release. +- **Pre-releases (`1.0.0-rc.N`):** version includes a pre-release suffix (`-rc.N`) and `publishConfig.tag` is `next` (`publish:readiness` enforces `prerelease → next, GA → latest`). ## Invariants the gates enforce diff --git a/packages/sdk/generated/src/designsystem.ts b/packages/sdk/generated/src/designsystem.ts index d0792d3..c53efb3 100644 --- a/packages/sdk/generated/src/designsystem.ts +++ b/packages/sdk/generated/src/designsystem.ts @@ -63,6 +63,12 @@ export class DesignSystem { private client: StitchToolClientSpec, data: any, ) { + Object.defineProperty(this, "client", { + value: client, + enumerable: false, + writable: true, + configurable: true, + }); this.data = typeof data === "object" && data !== null ? data : undefined; const _rawName = typeof data === "string" diff --git a/packages/sdk/generated/src/project.ts b/packages/sdk/generated/src/project.ts index 870ac96..04ef45a 100644 --- a/packages/sdk/generated/src/project.ts +++ b/packages/sdk/generated/src/project.ts @@ -68,6 +68,12 @@ export class Project { protected client: StitchToolClientSpec, data: any, ) { + Object.defineProperty(this, "client", { + value: client, + enumerable: false, + writable: true, + configurable: true, + }); this.data = typeof data === "object" && data !== null ? data : undefined; const _rawName = typeof data === "string" @@ -128,12 +134,18 @@ export class Project { modelId?: | "MODEL_ID_UNSPECIFIED" | "GEMINI_3_8_FLASH" - | "GEMINI_3_5_FLASH_LITE"; + | "GEMINI_3_5_FLASH_LITE" + | "GEMINI_3_PRO" + | "GEMINI_3_FLASH" + | "GEMINI_3_1_PRO"; }, modelId?: | "MODEL_ID_UNSPECIFIED" | "GEMINI_3_8_FLASH" - | "GEMINI_3_5_FLASH_LITE", + | "GEMINI_3_5_FLASH_LITE" + | "GEMINI_3_PRO" + | "GEMINI_3_FLASH" + | "GEMINI_3_1_PRO", ): Promise> { const options = typeof deviceTypeOrOptions === "object" && diff --git a/packages/sdk/generated/src/screen.ts b/packages/sdk/generated/src/screen.ts index 0e8eae3..738f4ad 100644 --- a/packages/sdk/generated/src/screen.ts +++ b/packages/sdk/generated/src/screen.ts @@ -64,6 +64,12 @@ export class Screen { protected client: StitchToolClientSpec, data: any, ) { + Object.defineProperty(this, "client", { + value: client, + enumerable: false, + writable: true, + configurable: true, + }); this.data = typeof data === "object" && data !== null ? data : undefined; const _rawName = typeof data === "string" @@ -124,12 +130,18 @@ export class Screen { modelId?: | "MODEL_ID_UNSPECIFIED" | "GEMINI_3_8_FLASH" - | "GEMINI_3_5_FLASH_LITE"; + | "GEMINI_3_5_FLASH_LITE" + | "GEMINI_3_PRO" + | "GEMINI_3_FLASH" + | "GEMINI_3_1_PRO"; }, modelId?: | "MODEL_ID_UNSPECIFIED" | "GEMINI_3_8_FLASH" - | "GEMINI_3_5_FLASH_LITE", + | "GEMINI_3_5_FLASH_LITE" + | "GEMINI_3_PRO" + | "GEMINI_3_FLASH" + | "GEMINI_3_1_PRO", ): Promise> { const options = typeof deviceTypeOrOptions === "object" && @@ -194,12 +206,18 @@ export class Screen { modelId?: | "MODEL_ID_UNSPECIFIED" | "GEMINI_3_8_FLASH" - | "GEMINI_3_5_FLASH_LITE"; + | "GEMINI_3_5_FLASH_LITE" + | "GEMINI_3_PRO" + | "GEMINI_3_FLASH" + | "GEMINI_3_1_PRO"; }, modelId?: | "MODEL_ID_UNSPECIFIED" | "GEMINI_3_8_FLASH" - | "GEMINI_3_5_FLASH_LITE", + | "GEMINI_3_5_FLASH_LITE" + | "GEMINI_3_PRO" + | "GEMINI_3_FLASH" + | "GEMINI_3_1_PRO", ): Promise> { const options = typeof deviceTypeOrOptions === "object" && diff --git a/packages/sdk/generated/src/tool-definitions.ts b/packages/sdk/generated/src/tool-definitions.ts index 961bf2c..1bdbc83 100644 --- a/packages/sdk/generated/src/tool-definitions.ts +++ b/packages/sdk/generated/src/tool-definitions.ts @@ -186,6 +186,9 @@ export const toolDefinitions: ToolDefinition[] = [ "MODEL_ID_UNSPECIFIED", "GEMINI_3_8_FLASH", "GEMINI_3_5_FLASH_LITE", + "GEMINI_3_PRO", + "GEMINI_3_FLASH", + "GEMINI_3_1_PRO", ], type: "string", "x-google-enum-descriptions": [ @@ -240,6 +243,9 @@ export const toolDefinitions: ToolDefinition[] = [ "MODEL_ID_UNSPECIFIED", "GEMINI_3_8_FLASH", "GEMINI_3_5_FLASH_LITE", + "GEMINI_3_PRO", + "GEMINI_3_FLASH", + "GEMINI_3_1_PRO", ], type: "string", "x-google-enum-descriptions": [ @@ -302,6 +308,9 @@ export const toolDefinitions: ToolDefinition[] = [ "MODEL_ID_UNSPECIFIED", "GEMINI_3_8_FLASH", "GEMINI_3_5_FLASH_LITE", + "GEMINI_3_PRO", + "GEMINI_3_FLASH", + "GEMINI_3_1_PRO", ], type: "string", "x-google-enum-descriptions": [ diff --git a/packages/sdk/generated/stitch-sdk.lock b/packages/sdk/generated/stitch-sdk.lock index 597a4de..685c2cb 100644 --- a/packages/sdk/generated/stitch-sdk.lock +++ b/packages/sdk/generated/stitch-sdk.lock @@ -1,12 +1,16 @@ { "schemaVersion": 1, "generated": { - "generatedAt": "2026-09-23T18:39:30.614Z", - "sourceHash": "sha256:a126fbb6fcc1161fd4de0d05e23cbaceacccddeb61f60fa54433d47aba54ff15", + "generatedAt": "2026-09-25T04:45:34.219Z", + "sourceHash": "sha256:aad79acad078a250f95d5e58767a9082c41be37d4f5565bf307c96f1cd654b38", "manifestHash": "sha256:88ec3dfa066ce5060be236b0a5c0b5fb51671a7dd60d05556c028e6c4c8f0e1c", "domainMapHash": "sha256:a6177cc7e2f44d2d536928d4e0ae7bd51ed2dd3393235787216b976498c3b1b2", "fileCount": 8, - "repairedTools": [] + "repairedTools": [ + "generate_screen_from_text", + "edit_screens", + "generate_variants" + ] }, "domainMap": { "generatedAt": "2026-09-23T18:36:09.460Z", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 95e4819..07a3634 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -58,7 +58,7 @@ "publishConfig": { "registry": "https://wombat-dressing-room.appspot.com", "access": "public", - "tag": "next" + "tag": "latest" }, "scripts": { "build": "bun scripts/inject-version.ts && tsc", diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index a08c171..9df4c5d 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -209,6 +209,23 @@ function normalizeTransportError(err: unknown, toolName: string): unknown { retryAfter: parseRetryAfter(retryAfterVal), }); } + if (err instanceof Error) { + const causeMsg = + err.cause instanceof Error ? err.cause.message : String(err.cause ?? ""); + const combined = `${err.message} ${causeMsg}`; + if ( + /fetch failed|econnreset|socket|other side closed|econnrefused|etimedout|network/i.test( + combined, + ) + ) { + return new StitchError({ + code: "NETWORK_ERROR", + message: `Tool Call Failed [${toolName}]: ${err.message}`, + recoverable: true, + toolName, + }); + } + } return err; } @@ -236,6 +253,11 @@ export class StitchToolClient implements StitchToolClientSpec { private localVirtualTools: VirtualToolDefinition[] = []; public entities: EntityManager; + /** Whether close() has been called on this client. */ + public get closed(): boolean { + return this.isClosed; + } + constructor( inputConfig?: Partial & { localVirtualTools?: VirtualToolDefinition[]; @@ -387,6 +409,7 @@ export class StitchToolClient implements StitchToolClientSpec { const maxAttempts = retry ? retry.attempts : 1; for (let attempt = 0; ; attempt++) { + if (!this.isConnected) await this.connect(); try { const result = await this.client.callTool( { name, arguments: args }, @@ -395,13 +418,19 @@ export class StitchToolClient implements StitchToolClientSpec { ); return this.parseToolResponse(result, name); } catch (rawErr) { - // Normalize transport HTTP errors first, so a real 429 is both - // classified and retry-eligible (it never reaches parseToolResult). + // Normalize transport HTTP / network errors first, so a real 429 or + // transient socket reset is classified and retry-eligible. const err = normalizeTransportError(rawErr, name); + if (err instanceof StitchError && err.code === "NETWORK_ERROR") { + this.isConnected = false; + this.connectPromise = null; + } const isRetryable = retry !== null && err instanceof StitchError && - (err.code === "RATE_LIMITED" || err.code === "SERVICE_UNAVAILABLE"); + (err.code === "RATE_LIMITED" || + err.code === "SERVICE_UNAVAILABLE" || + err.code === "NETWORK_ERROR"); if (!isRetryable || attempt >= maxAttempts - 1) throw err; debugLog("retry", `${err.code} on ${name}; backing off`, { attempt: attempt + 1, diff --git a/packages/sdk/src/entity-manager.ts b/packages/sdk/src/entity-manager.ts index 3057983..da6b38e 100644 --- a/packages/sdk/src/entity-manager.ts +++ b/packages/sdk/src/entity-manager.ts @@ -82,7 +82,12 @@ export class EntityManager { } constructor(client: any, opts?: { enabled?: boolean }) { - this.client = client; + Object.defineProperty(this, "client", { + value: client, + enumerable: false, + writable: true, + configurable: true, + }); this.enabled = opts?.enabled ?? true; } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 351aede..fc66650 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -36,6 +36,13 @@ export { StitchError, StitchErrorCode } from "./spec/errors.js"; // Resource name utilities export { parseResourceName } from "./utils.js"; +// Schema repair utilities +export { + repairToolSchemas, + repairSchema, + collectDefPool, +} from "./schema-repair.js"; + // Tool catalog (re-exported with deprecation for 0.4.0 bridge; preferred subpath is @google/stitch-sdk/tools) /** @deprecated Import from "@google/stitch-sdk/tools" to optimize bundle size. */ export { toolDefinitions, toolMap } from "./tools.js"; diff --git a/packages/sdk/src/schema-repair.ts b/packages/sdk/src/schema-repair.ts index af3b661..047e0ce 100644 --- a/packages/sdk/src/schema-repair.ts +++ b/packages/sdk/src/schema-repair.ts @@ -103,46 +103,113 @@ function collectRefTargets( } /** - * Repair a single JSON Schema by injecting any missing well-known $defs - * that are referenced via $ref but not present. + * Legacy 0.3.5 modelId enum literals preserved during the 0.4.0 bridge so + * existing callers and Zod tool definitions accept both 0.3.5 and 0.4.0 IDs. + */ +const LEGACY_MODEL_IDS = [ + "GEMINI_3_PRO", + "GEMINI_3_FLASH", + "GEMINI_3_1_PRO", +] as const; + +/** + * Collect every `$defs` entry present across all tools' `inputSchema` and + * `outputSchema`, merged on top of `WELL_KNOWN_DEFS`. + * + * Server-provided `$defs` take precedence over `WELL_KNOWN_DEFS` stubs so + * sibling tools share the richest live schema definitions available. + */ +export function collectDefPool(tools: Tool[]): Record { + const pool: Record = { ...WELL_KNOWN_DEFS }; + + for (const tool of tools) { + const inputDefs = (tool.inputSchema as Record | undefined) + ?.$defs; + if (inputDefs && typeof inputDefs === "object") { + for (const [name, def] of Object.entries(inputDefs)) { + if (def && typeof def === "object") { + pool[name] = def as object; + } + } + } + + const outputDefs = (tool as any).outputSchema?.$defs; + if (outputDefs && typeof outputDefs === "object") { + for (const [name, def] of Object.entries(outputDefs)) { + if (def && typeof def === "object") { + pool[name] = def as object; + } + } + } + } + + return pool; +} + +/** + * Repair a single JSON Schema by injecting any missing $defs that are + * referenced via $ref (including transitive references inside injected $defs) + * and preserving legacy 0.3.5 `modelId` enum literals when present. * * Mutates the schema in place and returns it for convenience. */ -export function repairSchema(schema: Record): Record { +export function repairSchema( + schema: Record, + defPool: Record = WELL_KNOWN_DEFS, +): Record { if (!schema || typeof schema !== "object") return schema; - const referencedDefs = collectRefTargets(schema); - if (referencedDefs.size === 0) return schema; + // Preserve 0.3.5 modelId enum values alongside new server modelId literals + const modelIdEnum = schema.properties?.modelId?.enum; + if (Array.isArray(modelIdEnum)) { + for (const legacyId of LEGACY_MODEL_IDS) { + if (!modelIdEnum.includes(legacyId)) { + modelIdEnum.push(legacyId); + } + } + } + + const initialRefs = collectRefTargets(schema); + if (initialRefs.size === 0) return schema; // Ensure $defs block exists schema.$defs = schema.$defs || {}; - for (const defName of referencedDefs) { - // Only inject if: (a) the def is missing, and (b) we have a well-known stub - if (!schema.$defs[defName] && WELL_KNOWN_DEFS[defName]) { - schema.$defs[defName] = { ...WELL_KNOWN_DEFS[defName] }; + // Multi-pass resolution for transitive $ref dependencies inside injected $defs + const MAX_PASSES = 10; + for (let pass = 0; pass < MAX_PASSES; pass++) { + const referencedDefs = collectRefTargets(schema); + let injected = false; + for (const defName of referencedDefs) { + if (!schema.$defs[defName] && defPool[defName]) { + schema.$defs[defName] = structuredClone(defPool[defName]); + injected = true; + } } + if (!injected) break; } return schema; } /** - * Apply schema repair to every tool's inputSchema and outputSchema. + * Apply schema repair to every tool's inputSchema and outputSchema using a + * shared cross-tool `$defs` pool. * * This MUST run before the MCP SDK's AJV validator sees the schemas. * Mutates tools in place. */ export function repairToolSchemas(tools: Tool[]): void { + const pool = collectDefPool(tools); for (const tool of tools) { if (tool.inputSchema && typeof tool.inputSchema === "object") { - repairSchema(tool.inputSchema as Record); + repairSchema(tool.inputSchema as Record, pool); } // outputSchema was added in MCP SDK ≥1.27 and is the primary crash vector: // Client.cacheToolMetadata() eagerly compiles outputSchema with AJV. const anyTool = tool as any; if (anyTool.outputSchema && typeof anyTool.outputSchema === "object") { - repairSchema(anyTool.outputSchema); + repairSchema(anyTool.outputSchema, pool); } } } diff --git a/packages/sdk/src/singleton.ts b/packages/sdk/src/singleton.ts index 026914e..060ffb8 100644 --- a/packages/sdk/src/singleton.ts +++ b/packages/sdk/src/singleton.ts @@ -78,7 +78,7 @@ export function getOrCreateClient( ): StitchToolClient { const { resolved, key } = resolveAndKey(config); - if (_client && key !== _clientCacheKey) { + if (_client && (_client.closed || key !== _clientCacheKey)) { _client.close().catch(() => {}); _client = null; _stitch = null; @@ -145,7 +145,17 @@ function getMethodWrapper( if (cached) return cached; let wrapper: ((...args: unknown[]) => unknown) | undefined; - if (CLIENT_METHODS.has(prop)) { + if (prop === "close") { + wrapper = async () => { + const current = _client; + _client = null; + _clientCacheKey = null; + _stitch = null; + if (current) { + await current.close(); + } + }; + } else if (CLIENT_METHODS.has(prop)) { wrapper = (...args: unknown[]) => { const client = getOrCreateClient(); return ( diff --git a/packages/sdk/test/unit/bridge-0.4.0-premortem.test.ts b/packages/sdk/test/unit/bridge-0.4.0-premortem.test.ts index 2cb4ac2..97b8129 100644 --- a/packages/sdk/test/unit/bridge-0.4.0-premortem.test.ts +++ b/packages/sdk/test/unit/bridge-0.4.0-premortem.test.ts @@ -12,129 +12,215 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { describe, it, expect } from "vitest"; -import { Project, Screen, DesignSystem, Generation } from "../../src/index.js"; -import { EntityManager } from "../../src/entity-manager.js"; - -function makeMockClient() { - const client: any = { - callTool: async () => ({}), - }; - client.entities = new EntityManager(client); - return client; -} - -describe("0.4.0 Non-Breaking Bridge Pre-Mortem Regressions", () => { - describe("Tiger 1: Direct Entity Construction Hydration", () => { - it("hydrates Project from MCP resource name object { name: 'projects/123' }", () => { - const client = makeMockClient(); - const project = new Project(client, { - name: "projects/123", - title: "My App", - }); - expect(project.projectId).toBe("123"); - expect(project.id).toBe("123"); - expect(project.title).toBe("My App"); - }); +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + stitch, + resetStitchSingleton, + StitchToolClient, + Stitch, + DesignSystem, + StitchError, +} from "../../src/index.js"; +import { getOrCreateClient } from "../../src/singleton.js"; +import { toolMap } from "../../src/tools.js"; + +describe("0.4.0 Premortem Tigers & Reconciled PRs (#363, #368)", () => { + beforeEach(() => { + resetStitchSingleton(); + }); - it("hydrates Project from prefixed resource string 'projects/123'", () => { - const client = makeMockClient(); - const project = new Project(client, "projects/123"); - expect(project.projectId).toBe("123"); - expect(project.id).toBe("123"); - }); + afterEach(() => { + resetStitchSingleton(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); - it("hydrates Project from object with { id: '456' }", () => { - const client = makeMockClient(); - const project = new Project(client, { id: "456", title: "Fallback ID" }); - expect(project.projectId).toBe("456"); - expect(project.id).toBe("456"); + describe("Tiger 1: publishConfig.tag is 'latest'", () => { + it("declares publishConfig.tag === 'latest' in packages/sdk/package.json", () => { + const pkgPath = resolve(import.meta.dirname, "../../package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + expect(pkg.publishConfig?.tag).toBe("latest"); }); + }); - it("hydrates Screen from multi-segment resource string 'projects/p-1/screens/s-2'", () => { - const client = makeMockClient(); - const screen = new Screen(client, "projects/p-1/screens/s-2"); - expect(screen.projectId).toBe("p-1"); - expect(screen.screenId).toBe("s-2"); - expect(screen.id).toBe("s-2"); + describe("Tiger 2: stitch.close() lifecycle safety", () => { + it("await stitch.close() before first use is a safe no-op without credentials", async () => { + vi.stubEnv("STITCH_API_KEY", ""); + vi.stubEnv("STITCH_ACCESS_TOKEN", ""); + + await expect(stitch.close()).resolves.toBeUndefined(); }); - it("hydrates Screen from MCP resource object { name: 'projects/p-1/screens/s-2' }", () => { - const client = makeMockClient(); - const screen = new Screen(client, { - name: "projects/p-1/screens/s-2", - title: "Login Screen", - }); - expect(screen.projectId).toBe("p-1"); - expect(screen.screenId).toBe("s-2"); - expect(screen.id).toBe("s-2"); - expect(screen.title).toBe("Login Screen"); + it("await stitch.close() resets the singleton so subsequent calls create a fresh client", async () => { + vi.stubEnv("STITCH_API_KEY", "test-key-1"); + + const firstClient = getOrCreateClient(); + expect(firstClient.closed).toBe(false); + + await stitch.close(); + expect(firstClient.closed).toBe(true); + + const secondClient = getOrCreateClient(); + expect(secondClient).not.toBe(firstClient); + expect(secondClient.closed).toBe(false); }); - it("hydrates DesignSystem from multi-segment resource string 'projects/p-1/assets/a-9'", () => { - const client = makeMockClient(); - const ds = new DesignSystem(client, "projects/p-1/assets/a-9"); - expect(ds.projectId).toBe("p-1"); - expect(ds.assetId).toBe("a-9"); - expect(ds.id).toBe("a-9"); + it("getOrCreateClient() replaces a cached client that was closed directly", async () => { + vi.stubEnv("STITCH_API_KEY", "test-key-1"); + + const firstClient = getOrCreateClient(); + await firstClient.close(); + expect(firstClient.closed).toBe(true); + + const secondClient = getOrCreateClient(); + expect(secondClient).not.toBe(firstClient); + expect(secondClient.closed).toBe(false); }); }); - describe("Tiger 2: Generation Proxy instanceof, Spread, and EntityManager.dispose", () => { - it("satisfies both instanceof Screen and instanceof Generation at runtime", () => { - const client = makeMockClient(); - const s = client.entities.resolve(Screen, ["projectId", "screenId"], { - projectId: "p1", - screenId: "s1", - title: "Home", + describe("Tiger 3: Legacy 0.3.5 modelId union compatibility", () => { + it("preserves GEMINI_3_PRO, GEMINI_3_FLASH, and GEMINI_3_1_PRO in toolMap", () => { + const genEnum = ( + toolMap.get("generate_screen_from_text")?.inputSchema.properties + .modelId as any + ).enum; + const editEnum = ( + toolMap.get("edit_screens")?.inputSchema.properties.modelId as any + ).enum; + + for (const legacyId of [ + "GEMINI_3_PRO", + "GEMINI_3_FLASH", + "GEMINI_3_1_PRO", + ]) { + expect(genEnum).toContain(legacyId); + expect(editEnum).toContain(legacyId); + } + }); + + it("accepts legacy 0.3.5 modelId values on project.generate and screen.edit", async () => { + const client = new StitchToolClient({ apiKey: "k" }); + const callToolSpy = vi + .spyOn(client, "callTool") + .mockResolvedValue({ + outputComponents: [ + { + design: { + screens: [{ name: "projects/p1/screens/s1", title: "Home" }], + }, + }, + ], + } as any); + + const sdk = new Stitch(client); + const project = sdk.project("p1"); + + const gen = await project.generate( + "Dashboard", + "DESKTOP", + "GEMINI_3_PRO", + ); + expect(gen.id).toBe("s1"); + expect(callToolSpy).toHaveBeenCalledWith( + "generate_screen_from_text", + expect.objectContaining({ + modelId: "GEMINI_3_PRO", + }), + ); + + const edited = await gen.screens[0].edit("Make dark", { + modelId: "GEMINI_3_FLASH", }); - const gen = new Generation([s], { rawField: true }); + expect(edited.id).toBe("s1"); + expect(callToolSpy).toHaveBeenCalledWith( + "edit_screens", + expect.objectContaining({ + modelId: "GEMINI_3_FLASH", + }), + ); + }); + }); - expect(gen instanceof Generation).toBe(true); - expect(gen instanceof Screen).toBe(true); - expect(s instanceof Screen).toBe(true); - expect(s instanceof Generation).toBe(false); + describe("Tiger 4: JSON.stringify() and object spread on entities and Generation", () => { + it("serializes Project, Screen, DesignSystem, and Generation without cyclic TypeError", async () => { + const client = new StitchToolClient({ apiKey: "k" }); + vi.spyOn(client, "callTool").mockResolvedValue({ + outputComponents: [ + { + design: { + screens: [{ name: "projects/p1/screens/s1", title: "Home" }], + }, + }, + ], + } as any); + + const sdk = new Stitch(client); + const project = sdk.project("p1"); + const screen = project.screen("s1"); + const ds = new DesignSystem(client, "assets/ds1"); + const generation = await project.generate("Home"); + + expect(() => JSON.stringify(project)).not.toThrow(); + expect(() => JSON.stringify(screen)).not.toThrow(); + expect(() => JSON.stringify(ds)).not.toThrow(); + expect(() => JSON.stringify(generation)).not.toThrow(); + + expect(Object.keys(screen)).not.toContain("client"); + expect(Object.keys({ ...screen })).not.toContain("client"); + + client.entities.dispose(generation); }); + }); - it("includes Screen own properties (projectId, screenId, data) in Object.keys and object spread", () => { - const client = makeMockClient(); - const s = client.entities.resolve(Screen, ["projectId", "screenId"], { - projectId: "p1", - screenId: "s1", - title: "Home", - }); - const gen = new Generation([s], { rawField: true }); - - const keys = Object.keys(gen); - expect(keys).toContain("projectId"); - expect(keys).toContain("screenId"); - expect(keys).toContain("data"); - expect(keys).toContain("screens"); - - const spread = { ...gen }; - expect(spread.projectId).toBe("p1"); - expect(spread.screenId).toBe("s1"); - expect(spread.data?.title).toBe("Home"); + describe("PR #363: Reconnect on transient network error during callTool", () => { + it("reconnects and retries idempotent reads (list_*) after a transient fetch failure", async () => { + vi.useFakeTimers(); + try { + const client = new StitchToolClient({ apiKey: "k" }); + client["isConnected"] = true; + + const connectSpy = vi + .spyOn(client, "connect") + .mockImplementation(async () => { + client["isConnected"] = true; + }); + + client["client"].callTool = vi + .fn() + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValueOnce({ + isError: false, + content: [], + structuredContent: { projects: [{ name: "projects/p1" }] }, + }); + + const promise = client.callTool("list_projects", {}); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ projects: [{ name: "projects/p1" }] }); + } finally { + vi.useRealTimers(); + } }); - it("evicts wrapped Screen from EntityManager when client.entities.dispose(gen) is called", () => { - const client = makeMockClient(); - const s1 = client.entities.resolve(Screen, ["projectId", "screenId"], { - projectId: "p1", - screenId: "s1", - title: "Home", - }); - const gen = new Generation([s1], {}); + it("normalizes transient socket errors to StitchError(NETWORK_ERROR) and resets isConnected without retrying generative tools", async () => { + const client = new StitchToolClient({ apiKey: "k" }); + client["isConnected"] = true; + client["client"].callTool = vi + .fn() + .mockRejectedValueOnce(new Error("socket hang up (ECONNRESET)")); - // Dispose using the Generation handle directly (as a 0.x single-screen caller would) - client.entities.dispose(gen); + const err = await client + .callTool("generate_screen_from_text", { prompt: "hi" }) + .catch((e) => e); - const s2 = client.entities.resolve(Screen, ["projectId", "screenId"], { - projectId: "p1", - screenId: "s1", - title: "Home", - }); - expect(s2).not.toBe(s1); + expect(err).toBeInstanceOf(StitchError); + expect(err.code).toBe("NETWORK_ERROR"); + expect(client["isConnected"]).toBe(false); }); }); }); diff --git a/packages/sdk/test/unit/schema-repair.test.ts b/packages/sdk/test/unit/schema-repair.test.ts index 5397b6d..2b7064d 100644 --- a/packages/sdk/test/unit/schema-repair.test.ts +++ b/packages/sdk/test/unit/schema-repair.test.ts @@ -13,7 +13,11 @@ // limitations under the License. import { describe, it, expect } from "vitest"; -import { repairSchema, repairToolSchemas } from "../../src/schema-repair.js"; +import { + repairSchema, + repairToolSchemas, + collectDefPool, +} from "../../src/schema-repair.js"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; describe("repairSchema", () => { @@ -235,4 +239,84 @@ describe("repairToolSchemas", () => { it("should handle empty tools array", () => { expect(() => repairToolSchemas([])).not.toThrow(); }); + + it("should share $defs across sibling tools and resolve transitive $refs (PR #368)", () => { + const tools: any[] = [ + { + name: "create_design_system", + description: "Defines DesignTheme and ColorPalette in $defs", + inputSchema: { + type: "object", + $defs: { + DesignTheme: { + type: "object", + properties: { + palette: { $ref: "#/$defs/ColorPalette" }, + }, + }, + ColorPalette: { + type: "object", + properties: { + primary: { type: "string" }, + }, + }, + }, + properties: { + theme: { $ref: "#/$defs/DesignTheme" }, + }, + }, + }, + { + name: "update_design_system", + description: "References DesignTheme without defining it or ColorPalette", + inputSchema: { + type: "object", + properties: { + theme: { $ref: "#/$defs/DesignTheme" }, + }, + }, + }, + ]; + + const pool = collectDefPool(tools); + expect(pool.DesignTheme).toBeDefined(); + expect(pool.ColorPalette).toBeDefined(); + + repairToolSchemas(tools); + + const repairedDefs = tools[1].inputSchema.$defs; + expect(repairedDefs.DesignTheme).toBeDefined(); + // Transitive dependency referenced inside DesignTheme must also be injected + expect(repairedDefs.ColorPalette).toBeDefined(); + expect(repairedDefs.ColorPalette.properties.primary).toEqual({ + type: "string", + }); + }); + + it("should preserve legacy 0.3.5 modelId enum values alongside 0.4.0 values", () => { + const schema: Record = { + type: "object", + properties: { + modelId: { + type: "string", + enum: [ + "MODEL_ID_UNSPECIFIED", + "GEMINI_3_8_FLASH", + "GEMINI_3_5_FLASH_LITE", + ], + }, + }, + }; + + repairSchema(schema); + + expect(schema.properties.modelId.enum).toEqual([ + "MODEL_ID_UNSPECIFIED", + "GEMINI_3_8_FLASH", + "GEMINI_3_5_FLASH_LITE", + "GEMINI_3_PRO", + "GEMINI_3_FLASH", + "GEMINI_3_1_PRO", + ]); + }); }); diff --git a/scripts/generate-sdk.ts b/scripts/generate-sdk.ts index 02ee430..a727675 100644 --- a/scripts/generate-sdk.ts +++ b/scripts/generate-sdk.ts @@ -744,6 +744,9 @@ function buildConstructorBody( const statements: string[] = []; const params = config.constructorParams || []; const lastParam = params[params.length - 1]; + statements.push( + `Object.defineProperty(this, "client", { value: client, enumerable: false, writable: true, configurable: true });`, + ); statements.push( `this.data = typeof data === "object" && data !== null ? data : undefined;`, ); diff --git a/scripts/publish-readiness.ts b/scripts/publish-readiness.ts index 64cd6b1..db861fd 100644 --- a/scripts/publish-readiness.ts +++ b/scripts/publish-readiness.ts @@ -170,9 +170,10 @@ check("dist-tag matches release channel (prerelease→next, GA→latest)", () => `prerelease ${pkg.version} must NOT publish to 'latest' (tag=${tag}); use 'next'`, ); } else { - assert( - tag === "latest" || tag === "next", - `Release ${pkg.version} should publish to 'latest' or 'next' (tag=${tag})`, + assert.strictEqual( + tag, + "latest", + `GA ${pkg.version} should publish to 'latest' (tag=${tag})`, ); } }); diff --git a/scripts/test/__snapshots__/codegen-snapshot.test.ts.snap b/scripts/test/__snapshots__/codegen-snapshot.test.ts.snap index dd22d57..2a3dfb4 100644 --- a/scripts/test/__snapshots__/codegen-snapshot.test.ts.snap +++ b/scripts/test/__snapshots__/codegen-snapshot.test.ts.snap @@ -104,6 +104,12 @@ export class Gizmo { private client: StitchToolClientSpec, data: any, ) { + Object.defineProperty(this, "client", { + value: client, + enumerable: false, + writable: true, + configurable: true, + }); this.data = typeof data === "object" && data !== null ? data : undefined; const _rawName = typeof data === "string" @@ -249,6 +255,12 @@ export class Widget { private client: StitchToolClientSpec, data: any, ) { + Object.defineProperty(this, "client", { + value: client, + enumerable: false, + writable: true, + configurable: true, + }); this.data = typeof data === "object" && data !== null ? data : undefined; const _rawName = typeof data === "string"