Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions packages/schema-generator/src/formatters/common-tools-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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<OpaqueRef<Stream<T>>> 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<T>), 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<T> union (T | OpaqueRef<T>)
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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<void>
// becomes Opaque<OpaqueRef<Stream<void>>> when returned inside OpaqueRef<BuiltInLLMState>

interface Stream<T> {
send(event: T): void;
}

interface OpaqueRefMethods<T> {
get(): T;
set(value: T): void;
}

// OpaqueRef<T> is an intersection of OpaqueRefMethods<T> and T
type OpaqueRef<T> = OpaqueRefMethods<T> & (
T extends Array<infer U> ? Array<OpaqueRef<U>>
: T extends object ? { [K in keyof T]: OpaqueRef<T[K]> }
: T
);

// Opaque<T> is a union: T | OpaqueRef<T>
type Opaque<T> =
| OpaqueRef<T>
| (T extends Array<infer U> ? Array<Opaque<U>>
: T extends object ? { [K in keyof T]: Opaque<T[K]> }
: T);

// This mimics BuiltInLLMState structure
interface LLMState {
pending: boolean;
result?: string;
error: unknown;
cancelGeneration: Stream<void>; // This becomes problematic when wrapped
}

// When we have OpaqueRef<LLMState>, the cancelGeneration property becomes:
// Opaque<OpaqueRef<Stream<void>>> which is the nested structure that triggered CT-1006
interface SchemaRoot {
state: OpaqueRef<LLMState>;
}