Skip to content

Commit 24a492d

Browse files
authored
fix(schema-generator): support Stream types nested in OpaqueRef wrappers (#1974)
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<OpaqueRef<Stream<void>>>). 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.
1 parent b07537b commit 24a492d

File tree

3 files changed

+150
-0
lines changed

3 files changed

+150
-0
lines changed

packages/schema-generator/src/formatters/common-tools-formatter.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,27 @@ export class CommonToolsFormatter implements TypeFormatter {
105105
context,
106106
wrapperInfo.kind,
107107
);
108+
} else {
109+
// If we detected a wrapper via typeNode but the type structure is complex
110+
// (e.g., wrapped in Opaque union), recursively unwrap to find the base wrapper type
111+
const unwrappedType = this.recursivelyUnwrapOpaqueRef(
112+
type,
113+
resolvedWrapper.kind,
114+
context.typeChecker,
115+
);
116+
if (unwrappedType) {
117+
const nodeToPass = n && ts.isTypeReferenceNode(n) && n.typeArguments
118+
? n
119+
: resolvedWrapper.node;
120+
121+
return this.formatWrapperType(
122+
unwrappedType.typeRef,
123+
nodeToPass,
124+
context,
125+
unwrappedType.kind,
126+
);
127+
}
128+
// If we couldn't unwrap, fall through to regular handling
108129
}
109130
}
110131

@@ -216,6 +237,61 @@ export class CommonToolsFormatter implements TypeFormatter {
216237
return { ...innerSchema, [propertyName]: true };
217238
}
218239

240+
/**
241+
* Recursively unwrap OpaqueRef layers to find a wrapper type (Cell/Stream/OpaqueRef).
242+
* This handles cases like Opaque<OpaqueRef<Stream<T>>> where the type is wrapped in
243+
* multiple layers of OpaqueRef due to the Opaque type's recursive definition.
244+
*/
245+
private recursivelyUnwrapOpaqueRef(
246+
type: ts.Type,
247+
targetWrapperKind: WrapperKind,
248+
checker: ts.TypeChecker,
249+
depth: number = 0,
250+
):
251+
| { type: ts.Type; typeRef: ts.TypeReference; kind: WrapperKind }
252+
| undefined {
253+
// Prevent infinite recursion
254+
if (depth > 10) {
255+
return undefined;
256+
}
257+
258+
// Check if this type itself is the target wrapper
259+
const wrapperInfo = this.getWrapperTypeInfo(type);
260+
if (wrapperInfo && wrapperInfo.kind === targetWrapperKind) {
261+
return { type, typeRef: wrapperInfo.typeRef, kind: wrapperInfo.kind };
262+
}
263+
264+
// If this is a union (e.g., from Opaque<T>), check each member
265+
if (type.flags & ts.TypeFlags.Union) {
266+
const unionType = type as ts.UnionType;
267+
for (const member of unionType.types) {
268+
// Try to unwrap this member
269+
const result = this.recursivelyUnwrapOpaqueRef(
270+
member,
271+
targetWrapperKind,
272+
checker,
273+
depth + 1,
274+
);
275+
if (result) return result;
276+
}
277+
}
278+
279+
// If this is an OpaqueRef type, extract its type argument and recurse
280+
if (this.isOpaqueRefType(type)) {
281+
const innerType = this.extractOpaqueRefTypeArgument(type, checker);
282+
if (innerType) {
283+
return this.recursivelyUnwrapOpaqueRef(
284+
innerType,
285+
targetWrapperKind,
286+
checker,
287+
depth + 1,
288+
);
289+
}
290+
}
291+
292+
return undefined;
293+
}
294+
219295
/**
220296
* Check if a type is an Opaque<T> union (T | OpaqueRef<T>)
221297
*/
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"$defs": {
3+
"LLMState": {
4+
"properties": {
5+
"cancelGeneration": {
6+
"asStream": true
7+
},
8+
"error": true,
9+
"pending": {
10+
"type": "boolean"
11+
},
12+
"result": {
13+
"type": "string"
14+
}
15+
},
16+
"required": [
17+
"cancelGeneration",
18+
"error",
19+
"pending"
20+
],
21+
"type": "object"
22+
}
23+
},
24+
"properties": {
25+
"state": {
26+
"$ref": "#/$defs/LLMState",
27+
"asOpaque": true
28+
}
29+
},
30+
"required": [
31+
"state"
32+
],
33+
"type": "object"
34+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Test case for CT-1006: Stream type nested in OpaqueRef inside Opaque union
2+
// This mimics the structure of BuiltInLLMState where cancelGeneration: Stream<void>
3+
// becomes Opaque<OpaqueRef<Stream<void>>> when returned inside OpaqueRef<BuiltInLLMState>
4+
5+
interface Stream<T> {
6+
send(event: T): void;
7+
}
8+
9+
interface OpaqueRefMethods<T> {
10+
get(): T;
11+
set(value: T): void;
12+
}
13+
14+
// OpaqueRef<T> is an intersection of OpaqueRefMethods<T> and T
15+
type OpaqueRef<T> = OpaqueRefMethods<T> & (
16+
T extends Array<infer U> ? Array<OpaqueRef<U>>
17+
: T extends object ? { [K in keyof T]: OpaqueRef<T[K]> }
18+
: T
19+
);
20+
21+
// Opaque<T> is a union: T | OpaqueRef<T>
22+
type Opaque<T> =
23+
| OpaqueRef<T>
24+
| (T extends Array<infer U> ? Array<Opaque<U>>
25+
: T extends object ? { [K in keyof T]: Opaque<T[K]> }
26+
: T);
27+
28+
// This mimics BuiltInLLMState structure
29+
interface LLMState {
30+
pending: boolean;
31+
result?: string;
32+
error: unknown;
33+
cancelGeneration: Stream<void>; // This becomes problematic when wrapped
34+
}
35+
36+
// When we have OpaqueRef<LLMState>, the cancelGeneration property becomes:
37+
// Opaque<OpaqueRef<Stream<void>>> which is the nested structure that triggered CT-1006
38+
interface SchemaRoot {
39+
state: OpaqueRef<LLMState>;
40+
}

0 commit comments

Comments
 (0)