cloudflare / cloudflare/agents
codemode: jsonSchemaToType drops sibling properties on root $ref and types allOf refinements as unknown
- Dominant language
- TypeScript
- Stars
- 5.6k
- Forks
- 711
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 53
Description
We hit this in production on `@cloudflare/codemode@0.5.1` (current `latest`); the code is the same on `main`. `jsonSchemaToTypeString` in `packages/codemode/src/json-schema-types.ts` throws away sibling keywords in two places, and in a codemode sandbox the generated signature is the only thing telling the model what a tool needs, so the model just stops passing those fields.
Some context on how we get here: our tool input contracts are Effect `Schema`. Connector tools (MCP / Composio) come in as JSON Schema, sometimes via AI SDK's `zodSchema(..., { useReferences: true })`, and we merge a few required Effect-typed fields (`label`, `doneLabel`) into every tool's `inputSchema` before it reaches codemode. Both bugs below bite that setup.
### 1. A root `$ref` drops sibling `properties` / `required`
```ts
jsonSchemaToType({
$ref: "#/$defs/Input",
$defs: { Input: { type: "object", properties: { query: { type: "string" } } } },
properties: { label: { type: "string" }, doneLabel: { type: "string" } },
required: ["label", "doneLabel"],
}, "RootRef");
```
What we get:
```ts
type RootRef = {
query?: string;
}
```
`label` and `doneLabel` are gone. The `$ref` branch returns the resolved type and never looks at anything else on the node. Zod v4's `toJSONSchema` with `reuse: "ref"` (which is what `useReferences: true` turns on) emits root-`$ref` schemas for reused objects, so any properties you add beside that ref are invisible to the model.
### 2. `allOf` wins over the sibling `type`, and constraint-only members become `unknown`
```ts
jsonSchemaToType({
type: "object",
properties: {
label: { type: "string", allOf: [{ minLength: 1 }] },
count: { type: "integer", allOf: [{ minimum: 0 }] },
},
required: ["label", "count"],
}, "Refinement");
```
What we get:
```ts
type Refinement = {
label: unknown;
count: unknown;
}
```
The `allOf` branch runs before the `type` branch and returns only the intersection of the members. `{ minLength: 1 }` has no `type`, so it renders as `unknown`, and the sibling `type: "string"` is never consulted. This is exactly how Effect `Schema` serialises refinements (`NonEmptyString`, `Int`, `Positive`, ...), so every refined field in an Effect-derived tool schema is typed `unknown`.
### What it looked like for us
Our narration fields were either missing from the signature (case 1) or typed `unknown` (case 2). The model, reasonably, left them out, and every connector call from the sandbox failed input validation. Took a while to trace back to the type generator because the schema itself validates fine; it's only the TypeScript rendering that's lossy.
### Proposed fix
In both branches, type the rest of the node (everything except `$ref` / `allOf`) and intersect it with the branch result when there's anything type-bearing left: case 1 becomes `{ query?: string } & { label: string; doneLabel: string }`. For `allOf`, drop members that render as `unknown` from the intersection so `string & unknown` collapses to `string`. I have a PR ready and will link it here.
Contributor guide
Assessment
This issue has not been assessed yet.