From 8b43021585a96de38294e343bf1182e7c7882235 Mon Sep 17 00:00:00 2001 From: Gideon Wald Date: Tue, 28 Oct 2025 11:24:08 -0700 Subject: [PATCH] fix(schema-generator): support Stream types nested in OpaqueRef wrappers Fixes CT-1006: "Error: Unexpected CommonTools type: Stream" when building patterns with llm() function. The issue occurred when Stream types were wrapped in nested OpaqueRef layers (e.g., Opaque>>). The schema generator's wrapper detection via typeNode worked, but getWrapperTypeInfo(type) failed due to complex union structure. Solution: Added recursivelyUnwrapOpaqueRef() method that: - Reuses existing isOpaqueRefType() and extractOpaqueRefTypeArgument() - Recursively unwraps nested OpaqueRef layers in unions - Finds the base wrapper type (Cell/Stream/OpaqueRef) Includes test fixture opaque-opaqueref-stream to verify the fix. --- .../src/formatters/common-tools-formatter.ts | 76 +++++++++++++++++++ .../opaque-opaqueref-stream.expected.json | 34 +++++++++ .../schema/opaque-opaqueref-stream.input.ts | 40 ++++++++++ 3 files changed, 150 insertions(+) create mode 100644 packages/schema-generator/test/fixtures/schema/opaque-opaqueref-stream.expected.json create mode 100644 packages/schema-generator/test/fixtures/schema/opaque-opaqueref-stream.input.ts diff --git a/packages/schema-generator/src/formatters/common-tools-formatter.ts b/packages/schema-generator/src/formatters/common-tools-formatter.ts index 5383bc445..e8d4c6c9e 100644 --- a/packages/schema-generator/src/formatters/common-tools-formatter.ts +++ b/packages/schema-generator/src/formatters/common-tools-formatter.ts @@ -105,6 +105,27 @@ export class CommonToolsFormatter implements TypeFormatter { context, wrapperInfo.kind, ); + } else { + // If we detected a wrapper via typeNode but the type structure is complex + // (e.g., wrapped in Opaque union), recursively unwrap to find the base wrapper type + const unwrappedType = this.recursivelyUnwrapOpaqueRef( + type, + resolvedWrapper.kind, + context.typeChecker, + ); + if (unwrappedType) { + const nodeToPass = n && ts.isTypeReferenceNode(n) && n.typeArguments + ? n + : resolvedWrapper.node; + + return this.formatWrapperType( + unwrappedType.typeRef, + nodeToPass, + context, + unwrappedType.kind, + ); + } + // If we couldn't unwrap, fall through to regular handling } } @@ -216,6 +237,61 @@ export class CommonToolsFormatter implements TypeFormatter { return { ...innerSchema, [propertyName]: true }; } + /** + * Recursively unwrap OpaqueRef layers to find a wrapper type (Cell/Stream/OpaqueRef). + * This handles cases like Opaque>> where the type is wrapped in + * multiple layers of OpaqueRef due to the Opaque type's recursive definition. + */ + private recursivelyUnwrapOpaqueRef( + type: ts.Type, + targetWrapperKind: WrapperKind, + checker: ts.TypeChecker, + depth: number = 0, + ): + | { type: ts.Type; typeRef: ts.TypeReference; kind: WrapperKind } + | undefined { + // Prevent infinite recursion + if (depth > 10) { + return undefined; + } + + // Check if this type itself is the target wrapper + const wrapperInfo = this.getWrapperTypeInfo(type); + if (wrapperInfo && wrapperInfo.kind === targetWrapperKind) { + return { type, typeRef: wrapperInfo.typeRef, kind: wrapperInfo.kind }; + } + + // If this is a union (e.g., from Opaque), check each member + if (type.flags & ts.TypeFlags.Union) { + const unionType = type as ts.UnionType; + for (const member of unionType.types) { + // Try to unwrap this member + const result = this.recursivelyUnwrapOpaqueRef( + member, + targetWrapperKind, + checker, + depth + 1, + ); + if (result) return result; + } + } + + // If this is an OpaqueRef type, extract its type argument and recurse + if (this.isOpaqueRefType(type)) { + const innerType = this.extractOpaqueRefTypeArgument(type, checker); + if (innerType) { + return this.recursivelyUnwrapOpaqueRef( + innerType, + targetWrapperKind, + checker, + depth + 1, + ); + } + } + + return undefined; + } + /** * Check if a type is an Opaque union (T | OpaqueRef) */ diff --git a/packages/schema-generator/test/fixtures/schema/opaque-opaqueref-stream.expected.json b/packages/schema-generator/test/fixtures/schema/opaque-opaqueref-stream.expected.json new file mode 100644 index 000000000..3a0e365a8 --- /dev/null +++ b/packages/schema-generator/test/fixtures/schema/opaque-opaqueref-stream.expected.json @@ -0,0 +1,34 @@ +{ + "$defs": { + "LLMState": { + "properties": { + "cancelGeneration": { + "asStream": true + }, + "error": true, + "pending": { + "type": "boolean" + }, + "result": { + "type": "string" + } + }, + "required": [ + "cancelGeneration", + "error", + "pending" + ], + "type": "object" + } + }, + "properties": { + "state": { + "$ref": "#/$defs/LLMState", + "asOpaque": true + } + }, + "required": [ + "state" + ], + "type": "object" +} diff --git a/packages/schema-generator/test/fixtures/schema/opaque-opaqueref-stream.input.ts b/packages/schema-generator/test/fixtures/schema/opaque-opaqueref-stream.input.ts new file mode 100644 index 000000000..8ed5f0814 --- /dev/null +++ b/packages/schema-generator/test/fixtures/schema/opaque-opaqueref-stream.input.ts @@ -0,0 +1,40 @@ +// Test case for CT-1006: Stream type nested in OpaqueRef inside Opaque union +// This mimics the structure of BuiltInLLMState where cancelGeneration: Stream +// becomes Opaque>> when returned inside OpaqueRef + +interface Stream { + send(event: T): void; +} + +interface OpaqueRefMethods { + get(): T; + set(value: T): void; +} + +// OpaqueRef is an intersection of OpaqueRefMethods and T +type OpaqueRef = OpaqueRefMethods & ( + T extends Array ? Array> + : T extends object ? { [K in keyof T]: OpaqueRef } + : T +); + +// Opaque is a union: T | OpaqueRef +type Opaque = + | OpaqueRef + | (T extends Array ? Array> + : T extends object ? { [K in keyof T]: Opaque } + : T); + +// This mimics BuiltInLLMState structure +interface LLMState { + pending: boolean; + result?: string; + error: unknown; + cancelGeneration: Stream; // This becomes problematic when wrapped +} + +// When we have OpaqueRef, the cancelGeneration property becomes: +// Opaque>> which is the nested structure that triggered CT-1006 +interface SchemaRoot { + state: OpaqueRef; +}