Skip to content

fix: normalize Gemini schemas for non-native transports - #119

Open
byapparov wants to merge 5 commits into
mainfrom
backport/gemini-schema
Open

byapparov wants to merge 5 commits into
mainfrom
backport/gemini-schema

Conversation

@byapparov

Copy link
Copy Markdown
Contributor

Relates to #111

Intent

Some non-native Gemini transports reject JSON Schema type arrays even though native Google adapters handle nullable unions correctly. The transform needs to normalize only where the transport requires it.

Expected Impact on Users

Gemini requests through OpenAI-compatible or other non-native transports are less likely to be rejected for nullable tool schemas, while native Google and Vertex requests retain their adapter-owned semantics.

Expected Outcomes

  • Non-native Gemini transports receive compatible anyOf/nullable schemas.
  • Native Google and Vertex adapters receive original type arrays.
  • Existing anyOf, oneOf, and allOf combiners are preserved.

Implementation

  • Port the upstream schema compatibility change with transport-aware deferral and combiner preservation.
  • Add direct transform tests and a pinned Google request capture proving nullable output is retained by the native adapter.

Scope Caveat

This is a malformed-tool-call prevention measure, not evidence of recovery effectiveness and not an automatic retry policy.

Test Plan

  • Transform coverage includes nested, mixed, null-only, and combiner schemas.
  • Native Google request capture and session LLM tests pass.

Verification

  • 124 transform tests and 12 session LLM tests passed.
  • CLI typecheck, formatting, and diff checks passed.

Risks and Rollout

The change is limited to schema transformation. Native adapter behavior remains the source of truth for Google and Vertex nullable handling.

@byapparov byapparov added this to the Enterprise Observability milestone Sep 14, 2026
Comment thread packages/cli/src/provider/transform.ts Outdated

// Remove properties/required from non-object types (Gemini rejects these)
if (result.type && result.type !== "object") {
if (result.type && result.type !== "object" && !hasCombiner(result)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Native deferral misses properties/required stripping.

--- a/packages/cli/src/provider/transform.ts
+++ b/packages/cli/src/provider/transform.ts
@@ -1027 +1027 @@
-        if (result.type && result.type !== "object" && !hasCombiner(result)) {
+        if (result.type && !Array.isArray(result.type) && result.type !== "object" && !hasCombiner(result)) {
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #119, packages/cli/src/provider/transform.ts:1027-1030):

Problem: Native deferral misses properties/required stripping
Detail: The new `native` flag only gates the type-array→anyOf split (line 998), but the "Remove properties/required from non-object types" guard at line 1027 still fires for native adapters because `result.type` remains an array and `["object","null"] !== "object"`. This deletes `properties`/`required` from nullable-object nodes that the deferral was supposed to leave untouched for @ai-sdk/google and @ai-sdk/google-vertex, contradicting the comment at lines 941-942 and silently stripping the tool parameter's shape. Repro: schema `{ type: ["object","null"], properties: { q: { type: "string" } }, required: ["q"] }` on a native Google/Vertex model — the split is skipped (native), but `result.type` (array) !== "object" evaluates truthy, so properties/required are deleted and the adapter receives an object schema with no properties. The new deferral tests only cover `{ type: ["string","null"] }`, which has no properties to lose.
Suggested fix: Skip the deletion when the type is still an array (only possible for native/combiner nodes after the split): `if (result.type && !Array.isArray(result.type) && result.type !== "object" && !hasCombiner(result))`. Add a test with `{ type: ["object","null"], properties: {...}, required: [...] }` on a native model asserting the schema is returned unchanged.

Suggested patch:
--- a/packages/cli/src/provider/transform.ts
+++ b/packages/cli/src/provider/transform.ts
@@ -1027 +1027 @@
-        if (result.type && result.type !== "object" && !hasCombiner(result)) {
+        if (result.type && !Array.isArray(result.type) && result.type !== "object" && !hasCombiner(result)) {

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The new native flag only gates the type-array→anyOf split (line 998), but the "Remove properties/required from non-object types" guard at line 1027 still fires for native adapters because result.type remains an array and ["object","null"] !== "object". This deletes properties/required from nullable-object nodes that the deferral was supposed to leave untouched for @ai-sdk/google and @ai-sdk/google-vertex, contradicting the comment at lines 941-942 and silently stripping the tool parameter's shape. Repro: schema { type: ["object","null"], properties: { q: { type: "string" } }, required: ["q"] } on a native Google/Vertex model — the split is skipped (native), but result.type (array) !== "object" evaluates truthy, so properties/required are deleted and the adapter receives an object schema with no properties. The new deferral tests only cover { type: ["string","null"] }, which has no properties to lose.

        }

        // Remove properties/required from non-object types (Gemini rejects these)
        if (result.type && result.type !== "object" && !hasCombiner(result)) {
          delete result.properties
          delete result.required
        }

Comment thread packages/cli/src/provider/transform.ts Outdated
if (types.length === 0) {
result.type = "null"
} else {
delete result.type

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Split nodes escape properties/required stripping.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #119, packages/cli/src/provider/transform.ts:1004-1008):

Problem: Split nodes escape properties/required stripping
Detail: The type-array split deletes `result.type` (line 1004) before the hygiene steps run, so a node like `{ type: ["string","null"], properties: {...}, required: [...] }` ends with `type` deleted and `anyOf` set; the truthy-`result.type` guard at 1027 and the `result.type === "object"` guard at 1011 then never fire. Post-PR, `properties`/`required` survive on a non-object node, which the code's own comment at line 1026 says Gemini rejects — pre-PR they were stripped. Same exemption applies to single-entry arrays like `["object"]`/`["array"]` non-null-only, which now get wrapped in anyOf instead of collapsing to a scalar type, skipping the required-filter and items-defaulting steps. Repro: non-native Gemini transport (e.g. providerID "github-copilot", api.id containing "gemini") + property `{ type: ["object","null"], properties: { q: {} }, required: ["q"] }` → emitted node is `{ anyOf: [{type:"object"}], properties: {...}, required: [...], nullable: true }`; the properties/required sit beside anyOf instead of inside the object branch, so the param shape can be dropped or rejected by the Gemini-side schema translation.
Suggested fix: When `types.length === 1 && !nullable`, collapse to `result.type = types[0]` instead of building anyOf (preserves the object/array hygiene steps). For genuinely mixed nodes, move `properties`/`required` into the `{ type: "object" }` anyOf branch (or strip them when no branch is object-typed) so the emitted schema keeps a valid shape.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The type-array split deletes result.type (line 1004) before the hygiene steps run, so a node like { type: ["string","null"], properties: {...}, required: [...] } ends with type deleted and anyOf set; the truthy-result.type guard at 1027 and the result.type === "object" guard at 1011 then never fire. Post-PR, properties/required survive on a non-object node, which the code's own comment at line 1026 says Gemini rejects — pre-PR they were stripped. Same exemption applies to single-entry arrays like ["object"]/["array"] non-null-only, which now get wrapped in anyOf instead of collapsing to a scalar type, skipping the required-filter and items-defaulting steps. Repro: non-native Gemini transport (e.g. providerID "github-copilot", api.id containing "gemini") + property { type: ["object","null"], properties: { q: {} }, required: ["q"] } → emitted node is { anyOf: [{type:"object"}], properties: {...}, required: [...], nullable: true }; the properties/required sit beside anyOf instead of inside the object branch, so the param shape can be dropped or rejected by the Gemini-side schema translation.

          const nullable = result.type.includes("null")
          const types = result.type.filter((entry: unknown) => entry !== "null")
          if (types.length === 0) {
            result.type = "null"
          } else {
            delete result.type
            result.anyOf = types.map((entry: unknown) => ({ type: entry }))
            if (nullable) result.nullable = true
          }

Comment thread packages/cli/src/provider/transform.ts Outdated
// Ensure items has at least a type if it has no schema keywords
// This handles nested arrays like { type: "array", items: { type: "array", items: {} } }
if (typeof result.items === "object" && !Array.isArray(result.items) && !result.items.type) {
if (isPlainObject(result.items) && !hasSchemaIntent(result.items)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Enum-only items no longer get a type.

Suggested change
if (isPlainObject(result.items) && !hasSchemaIntent(result.items)) {
Treat `enum`/`const` without `type` as still needing a type default: either drop `enum`/`const` from the hasSchemaIntent key list, or infer `items.type` from the enum values (all-string "string", all-number "number").
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #119, packages/cli/src/provider/transform.ts:1021-1022):

Problem: Enum-only items no longer get a type
Detail: The old check (`!result.items.type`) forced `type: "string"` on enum-only `items` nodes like `{ enum: ["a","b"] }`; the new `hasSchemaIntent` list includes `enum`, so such items now keep no `type` at all. Gemini function-declaration schemas expect enum fields to carry a STRING type, so transports that forward this schema to Gemini (the PR's exact target: github-copilot OpenAI-compatible) may drop the constraint or reject the declaration — a behavior regression from the previous forced type. Repro: non-native Gemini transport + `{ type: "array", items: { enum: ["red","green"] } }` → hasSchemaIntent sees `enum` and skips the default, emitting items with no `type`, where pre-PR the items became `{ enum: [...], type: "string" }`.
Suggested fix: Treat `enum`/`const` without `type` as still needing a type default: either drop `enum`/`const` from the hasSchemaIntent key list, or infer `items.type` from the enum values (all-string → "string", all-number → "number").

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The old check (!result.items.type) forced type: "string" on enum-only items nodes like { enum: ["a","b"] }; the new hasSchemaIntent list includes enum, so such items now keep no type at all. Gemini function-declaration schemas expect enum fields to carry a STRING type, so transports that forward this schema to Gemini (the PR's exact target: github-copilot OpenAI-compatible) may drop the constraint or reject the declaration — a behavior regression from the previous forced type. Repro: non-native Gemini transport + { type: "array", items: { enum: ["red","green"] } } → hasSchemaIntent sees enum and skips the default, emitting items with no type, where pre-PR the items became { enum: [...], type: "string" }.

        if (result.type === "array" && !hasCombiner(result)) {
          if (result.items == null) {
            result.items = {}
          }
          // Ensure items has at least a type if it has no schema keywords
          // This handles nested arrays like { type: "array", items: { type: "array", items: {} } }
          if (isPlainObject(result.items) && !hasSchemaIntent(result.items)) {
            result.items.type = "string"
          }
        }

Comment thread packages/cli/src/provider/transform.ts Outdated
typeof node === "object" && node !== null && !Array.isArray(node)
const hasCombiner = (node: unknown) =>
isPlainObject(node) && (Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf))
const hasSchemaIntent = (node: unknown) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 hasSchemaIntent omits constraint keywords.

Suggested change
const hasSchemaIntent = (node: unknown) => {
Either extend the key list with the constraint/format keywords (minimum, maximum, pattern, format, minLength, maxLength, minItems, maxItems, ...), or narrow the fallback to only add `type: "string"` when items has no keys at all, leaving constrained items untouched.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #119, packages/cli/src/provider/transform.ts:948-967):

Problem: hasSchemaIntent omits constraint keywords
Detail: The comment at line 1019 says items gets a type only "if it has no schema keywords", but hasSchemaIntent's key list omits common constraint/format keywords (minimum, maximum, exclusiveMinimum/Maximum, pattern, format, minLength/MaxLength, minItems/maxItems, multipleOf). An items node like `{ minimum: 0 }` is still forced to `type: "string"`, producing an invalid mix of a numeric constraint with a string type — inconsistent with the helper's stated intent (though it matches the old `!result.items.type` behavior, so not a regression).
Suggested fix: Either extend the key list with the constraint/format keywords (minimum, maximum, pattern, format, minLength, maxLength, minItems, maxItems, ...), or narrow the fallback to only add `type: "string"` when items has no keys at all, leaving constrained items untouched.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The comment at line 1019 says items gets a type only "if it has no schema keywords", but hasSchemaIntent's key list omits common constraint/format keywords (minimum, maximum, exclusiveMinimum/Maximum, pattern, format, minLength/MaxLength, minItems/maxItems, multipleOf). An items node like { minimum: 0 } is still forced to type: "string", producing an invalid mix of a numeric constraint with a string type — inconsistent with the helper's stated intent (though it matches the old !result.items.type behavior, so not a regression).

      const hasSchemaIntent = (node: unknown) => {
        if (!isPlainObject(node)) return false
        if (hasCombiner(node)) return true
        return [
          "type",
          "properties",
          "items",
          "prefixItems",
          "enum",
          "const",
          "$ref",
          "additionalProperties",
          "patternProperties",
          "required",
          "not",
          "if",
          "then",
          "else",
        ].some((key) => key in node)
      }

Comment thread packages/cli/src/provider/transform.ts Outdated
if (Array.isArray(result.type) && !hasCombiner(result) && !native) {
const nullable = result.type.includes("null")
const types = result.type.filter((entry: unknown) => entry !== "null")
if (types.length === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Empty type array collapses to type:"null".

Suggested change
if (types.length === 0) {
Only collapse to `result.type = "null"` when `nullable` is true (the array actually contained "null"); for an empty type array `delete result.type` so the node stays unconstrained.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #119, packages/cli/src/provider/transform.ts:1001-1002):

Problem: Empty type array collapses to type:"null"
Detail: When `type: []` (an empty array, no "null" entry) reaches the split, `types.length === 0` and `nullable` is false, yet the code sets `result.type = "null"` — silently converting an unconstrained (or invalid-but-tolerated) schema into "must be null". Downstream consumers will then validate every value as null instead of accepting anything. Only collapse to `"null"` when the array actually contained "null".
Suggested fix: Only collapse to `result.type = "null"` when `nullable` is true (the array actually contained "null"); for an empty type array `delete result.type` so the node stays unconstrained.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

When type: [] (an empty array, no "null" entry) reaches the split, types.length === 0 and nullable is false, yet the code sets result.type = "null" — silently converting an unconstrained (or invalid-but-tolerated) schema into "must be null". Downstream consumers will then validate every value as null instead of accepting anything. Only collapse to "null" when the array actually contained "null".

          const nullable = result.type.includes("null")
          const types = result.type.filter((entry: unknown) => entry !== "null")
          if (types.length === 0) {
            result.type = "null"
          } else {
            delete result.type
            result.anyOf = types.map((entry: unknown) => ({ type: entry }))
            if (nullable) result.nullable = true
          }

@aictrl-dev

aictrl-dev Bot commented Sep 14, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 1 · 🟡 4 · ⚪ 0 · 0/5 resolved

  • 🟡 packages/cli/src/provider/transform.ts:948-967 — hasSchemaIntent omits constraint keywords
  • 🟡 packages/cli/src/provider/transform.ts:1001-1002 — Empty type array collapses to type:"null"
  • 🟡 packages/cli/src/provider/transform.ts:1004-1008 — Split nodes escape properties/required stripping
  • 🟡 packages/cli/src/provider/transform.ts:1021-1022 — Enum-only items no longer get a type
  • 🟠 packages/cli/src/provider/transform.ts:1027-1030 — Native deferral misses properties/required stripping
🤖 Fix all 5 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #119 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/provider/transform.ts:948-967 — hasSchemaIntent omits constraint keywords
   Detail: The comment at line 1019 says items gets a type only "if it has no schema keywords", but hasSchemaIntent's key list omits common constraint/format keywords (minimum, maximum, exclusiveMinimum/Maximum, pattern, format, minLength/MaxLength, minItems/maxItems, multipleOf). An items node like `{ minimum: 0 }` is still forced to `type: "string"`, producing an invalid mix of a numeric constraint with a string type — inconsistent with the helper's stated intent (though it matches the old `!result.items.type` behavior, so not a regression).
   Suggested fix: Either extend the key list with the constraint/format keywords (minimum, maximum, pattern, format, minLength, maxLength, minItems, maxItems, ...), or narrow the fallback to only add `type: "string"` when items has no keys at all, leaving constrained items untouched.
2. packages/cli/src/provider/transform.ts:1001-1002 — Empty type array collapses to type:"null"
   Detail: When `type: []` (an empty array, no "null" entry) reaches the split, `types.length === 0` and `nullable` is false, yet the code sets `result.type = "null"` — silently converting an unconstrained (or invalid-but-tolerated) schema into "must be null". Downstream consumers will then validate every value as null instead of accepting anything. Only collapse to `"null"` when the array actually contained "null".
   Suggested fix: Only collapse to `result.type = "null"` when `nullable` is true (the array actually contained "null"); for an empty type array `delete result.type` so the node stays unconstrained.
3. packages/cli/src/provider/transform.ts:1004-1008 — Split nodes escape properties/required stripping
   Detail: The type-array split deletes `result.type` (line 1004) before the hygiene steps run, so a node like `{ type: ["string","null"], properties: {...}, required: [...] }` ends with `type` deleted and `anyOf` set; the truthy-`result.type` guard at 1027 and the `result.type === "object"` guard at 1011 then never fire. Post-PR, `properties`/`required` survive on a non-object node, which the code's own comment at line 1026 says Gemini rejects — pre-PR they were stripped. Same exemption applies to single-entry arrays like `["object"]`/`["array"]` non-null-only, which now get wrapped in anyOf instead of collapsing to a scalar type, skipping the required-filter and items-defaulting steps. Repro: non-native Gemini transport (e.g. providerID "github-copilot", api.id containing "gemini") + property `{ type: ["object","null"], properties: { q: {} }, required: ["q"] }` → emitted node is `{ anyOf: [{type:"object"}], properties: {...}, required: [...], nullable: true }`; the properties/required sit beside anyOf instead of inside the object branch, so the param shape can be dropped or rejected by the Gemini-side schema translation.
   Suggested fix: When `types.length === 1 && !nullable`, collapse to `result.type = types[0]` instead of building anyOf (preserves the object/array hygiene steps). For genuinely mixed nodes, move `properties`/`required` into the `{ type: "object" }` anyOf branch (or strip them when no branch is object-typed) so the emitted schema keeps a valid shape.
4. packages/cli/src/provider/transform.ts:1021-1022 — Enum-only items no longer get a type
   Detail: The old check (`!result.items.type`) forced `type: "string"` on enum-only `items` nodes like `{ enum: ["a","b"] }`; the new `hasSchemaIntent` list includes `enum`, so such items now keep no `type` at all. Gemini function-declaration schemas expect enum fields to carry a STRING type, so transports that forward this schema to Gemini (the PR's exact target: github-copilot OpenAI-compatible) may drop the constraint or reject the declaration — a behavior regression from the previous forced type. Repro: non-native Gemini transport + `{ type: "array", items: { enum: ["red","green"] } }` → hasSchemaIntent sees `enum` and skips the default, emitting items with no `type`, where pre-PR the items became `{ enum: [...], type: "string" }`.
   Suggested fix: Treat `enum`/`const` without `type` as still needing a type default: either drop `enum`/`const` from the hasSchemaIntent key list, or infer `items.type` from the enum values (all-string → "string", all-number → "number").
5. packages/cli/src/provider/transform.ts:1027-1030 — Native deferral misses properties/required stripping
   Detail: The new `native` flag only gates the type-array→anyOf split (line 998), but the "Remove properties/required from non-object types" guard at line 1027 still fires for native adapters because `result.type` remains an array and `["object","null"] !== "object"`. This deletes `properties`/`required` from nullable-object nodes that the deferral was supposed to leave untouched for @ai-sdk/google and @ai-sdk/google-vertex, contradicting the comment at lines 941-942 and silently stripping the tool parameter's shape. Repro: schema `{ type: ["object","null"], properties: { q: { type: "string" } }, required: ["q"] }` on a native Google/Vertex model — the split is skipped (native), but `result.type` (array) !== "object" evaluates truthy, so properties/required are deleted and the adapter receives an object schema with no properties. The new deferral tests only cover `{ type: ["string","null"] }`, which has no properties to lose.
   Suggested fix: Skip the deletion when the type is still an array (only possible for native/combiner nodes after the split): `if (result.type && !Array.isArray(result.type) && result.type !== "object" && !hasCombiner(result))`. Add a test with `{ type: ["object","null"], properties: {...}, required: [...] }` on a native model asserting the schema is returned unchanged.
📋 Out-of-diff findings (5)
Sev Location Finding
🟡 packages/cli/src/provider/transform.ts:948-967 hasSchemaIntent omits constraint keywords
🟡 packages/cli/src/provider/transform.ts:1001-1002 Empty type array collapses to type:"null"
🟡 packages/cli/src/provider/transform.ts:1004-1008 Split nodes escape properties/required stripping
🟡 packages/cli/src/provider/transform.ts:1021-1022 Enum-only items no longer get a type
🟠 packages/cli/src/provider/transform.ts:1027-1030 Native deferral misses properties/required stripping

Reviewed 3 files · 0 inline · view all 5 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov byapparov self-assigned this Sep 14, 2026
@byapparov

Copy link
Copy Markdown
Contributor Author

Review response — PR #119

Verified all five schema-transform findings, reproduced 15 failing regression cases before the fix, and addressed them on the existing branch.

Issues addressed (pushed to this PR)

  • Native nullable-object members were strippedpackages/cli/src/provider/transform.ts: classify original member types before hygiene; preserve object properties/required for native Google/Vertex adapter conversion. The actual Google request fixture verifies nullable object members survive (commit aff0dd33de).
  • Split unions bypassed type-specific cleanuppackages/cli/src/provider/transform.ts: clean before splitting, remove object-only fields from primitive unions, and place properties/required/items on the matching non-native object/array branches. Non-null single-entry type arrays collapse directly to their type (commit aff0dd33de).
  • Enum-only array items lost their typepackages/cli/src/provider/transform.ts: retain explicit string typing after the existing enum string conversion, including nullable-array request coverage (commit aff0dd33de).
  • Constraint-only items were forced to stringpackages/cli/src/provider/transform.ts: default only empty or annotation-only items; preserve numeric, format, pattern, array, and extension constraints without adding an unrelated type (commit aff0dd33de).
  • Empty type arrays became null-onlypackages/cli/src/provider/transform.ts: fail explicitly on invalid empty type arrays before native or proxy adapters can reinterpret them (commit aff0dd33de).

Validation: 155 focused transform/session tests passed, including an actual pinned Google adapter request capture. CLI typecheck, Prettier, and diff checks passed. Pushed normally without verification bypass flags. Fixtures validate request construction; no live-model acceptance claim is made.

Review claims verified false (no change needed)

None.

Not addressed here

None of the five findings are deferred. Data-layer verdict persistence is unavailable in this session: matched 0, written 0, verified 0, failed 0, unrecorded 5.

const nullable = types.includes("null")
const nonNull = types.filter((entry: unknown) => entry !== "null")
if (nonNull.length === 0) {
result.type = "null"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Null-only type array emits bare type:"null".

Suggested change
result.type = "null"
Emit `result.nullable = true` and delete `result.type` (leaving an unconstrained nullable schema) instead of `result.type = "null"`; update the `["null"]` test expectation accordingly.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #119, packages/cli/src/provider/transform.ts:1030):

Problem: Null-only type array emits bare type:"null"
Detail: When a type array contains only "null" (e.g. `type: ["null"]`), the non-native split sets `result.type = "null"`. Gemini's Schema.type enum has no NULL value — nullability is expressed via `nullable`, which is exactly what every other branch in this same block does (anyOf + `nullable: true`). On the non-native transports this PR targets precisely because their translation layer does not normalize, a bare `{type:"null"}` node is invalid on the wire (or coerced to TYPE_UNSPECIFIED). Not a regression (pre-PR sent `["null"]` raw, equally invalid), but the rewrite should match the convention it establishes everywhere else. The "collapses a null-only type array" test pins the current shape.
Suggested fix: Emit `result.nullable = true` and delete `result.type` (leaving an unconstrained nullable schema) instead of `result.type = "null"`; update the `["null"]` test expectation accordingly.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

When a type array contains only "null" (e.g. type: ["null"]), the non-native split sets result.type = "null". Gemini's Schema.type enum has no NULL value — nullability is expressed via nullable, which is exactly what every other branch in this same block does (anyOf + nullable: true). On the non-native transports this PR targets precisely because their translation layer does not normalize, a bare {type:"null"} node is invalid on the wire (or coerced to TYPE_UNSPECIFIED). Not a regression (pre-PR sent ["null"] raw, equally invalid), but the rewrite should match the convention it establishes everywhere else. The "collapses a null-only type array" test pins the current shape.

        if (Array.isArray(result.type) && !composed && !native) {
          const nullable = types.includes("null")
          const nonNull = types.filter((entry: unknown) => entry !== "null")
          if (nonNull.length === 0) {
            result.type = "null"
          } else if (nonNull.length === 1 && !nullable) {
            result.type = nonNull[0]
          } else {

Comment thread packages/cli/src/provider/transform.ts Outdated
// Apply type-specific cleanup before splitting; afterwards type no longer
// identifies whether the union contains object or array members.
// Native adapters own this conversion to preserve their nullability rules.
if (Array.isArray(result.type) && !composed && !native) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Combiner nodes keep raw type arrays on non-native.

Suggested change
if (Array.isArray(result.type) && !composed && !native) {
When `!native && composed && Array.isArray(result.type)`, consider dropping the redundant type array (expressing nullability via `nullable: true`) instead of passing it through, since the existing combiner already carries the union.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #119, packages/cli/src/provider/transform.ts:1026):

Problem: Combiner nodes keep raw type arrays on non-native
Detail: Nodes carrying both a type array and a pre-existing anyOf/oneOf/allOf skip the split (`!composed` at line 1026) and all hygiene steps (`!composed` at 997/1001/1018) on non-native transports, so e.g. `{type:["object","null"], anyOf:[...]}` still reaches the Gemini-side translation with a raw type array — the exact shape this PR exists to eliminate for non-native transports. The "preserves a pre-existing %s on a type-array node" test pins the passthrough for github-copilot. Skipping is a deliberate tradeoff (splitting would create competing nested combiners), but it leaves a known-bad wire shape unhandled on the non-native path.
Suggested fix: When `!native && composed && Array.isArray(result.type)`, consider dropping the redundant type array (expressing nullability via `nullable: true`) instead of passing it through, since the existing combiner already carries the union.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Nodes carrying both a type array and a pre-existing anyOf/oneOf/allOf skip the split (!composed at line 1026) and all hygiene steps (!composed at 997/1001/1018) on non-native transports, so e.g. {type:["object","null"], anyOf:[...]} still reaches the Gemini-side translation with a raw type array — the exact shape this PR exists to eliminate for non-native transports. The "preserves a pre-existing %s on a type-array node" test pins the passthrough for github-copilot. Skipping is a deliberate tradeoff (splitting would create competing nested combiners), but it leaves a known-bad wire shape unhandled on the non-native path.

        // Apply type-specific cleanup before splitting; afterwards type no longer
        // identifies whether the union contains object or array members.
        // Native adapters own this conversion to preserve their nullability rules.
        if (Array.isArray(result.type) && !composed && !native) {
          const nullable = types.includes("null")
          const nonNull = types.filter((entry: unknown) => entry !== "null")

Comment thread packages/cli/src/provider/transform.ts Outdated
}
}

if (Array.isArray(result.type) && result.type.length === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Empty type array throws even for native adapters.

Suggested change
if (Array.isArray(result.type) && result.type.length === 0) {
Either skip the throw when `native` (let the pinned adapter decide), or coerce `type: []` to an unconstrained node (`delete result.type`) instead of throwing; keep the loud failure only for non-native transports if desired.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #119, packages/cli/src/provider/transform.ts:990-992):

Problem: Empty type array throws even for native adapters
Detail: The hard throw on `type: []` fires for every Gemini path, including native @ai-sdk/google / google-vertex models that this same PR otherwise defers to — the `!native` guard protects only the split (1026), not the throw (990). One malformed nested property anywhere in any tool schema now makes ProviderTransform.schema throw and kills the whole request for native google/vertex users, where pre-PR the schema passed through to the adapter. The test ("rejects an empty type array before %s converts it") shows this fail-fast is deliberate, but it converts a local, single-tool schema defect into a total request failure even on paths the PR explicitly leaves to the native adapter.
Suggested fix: Either skip the throw when `native` (let the pinned adapter decide), or coerce `type: []` to an unconstrained node (`delete result.type`) instead of throwing; keep the loud failure only for non-native transports if desired.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The hard throw on type: [] fires for every Gemini path, including native @ai-sdk/google / google-vertex models that this same PR otherwise defers to — the !native guard protects only the split (1026), not the throw (990). One malformed nested property anywhere in any tool schema now makes ProviderTransform.schema throw and kills the whole request for native google/vertex users, where pre-PR the schema passed through to the adapter. The test ("rejects an empty type array before %s converts it") shows this fail-fast is deliberate, but it converts a local, single-tool schema defect into a total request failure even on paths the PR explicitly leaves to the native adapter.

        if (Array.isArray(result.type) && result.type.length === 0) {
          throw new Error("Gemini tool schema contains an empty type array")
        }
        const types = Array.isArray(result.type) ? result.type : result.type ? [result.type] : []
        const composed = hasCombiner(result)

Comment thread packages/cli/src/provider/transform.ts Outdated
...(entry === "array" && result.items !== undefined ? { items: result.items } : {}),
}))
// Member schemas belong to their typed branch, not beside anyOf.
delete result.properties

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Split mutates caller's schema object in place.

Suggested change
delete result.properties
Deep-clone at sanitizeGemini entry (structuredClone or rebuild objects during recursion) so ProviderTransform.schema is pure; at minimum clone nodes before entering the split branch.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #119, packages/cli/src/provider/transform.ts:1042-1044):

Problem: Split mutates caller's schema object in place
Detail: sanitizeGemini rewrites nodes in place (pre-existing pattern: `result.items.type = "string"`, `delete result.properties`), but the new split is far more destructive: it deletes properties/required/items and installs anyOf/nullable on the caller's schema object. If a tool's inputSchema object is registered once and reused across requests or models (the new tests build fresh literals, so they won't catch it), the first Gemini request corrupts the shared schema and a subsequent non-Gemini model receives the Gemini-mangled anyOf/nullable shape. No clone is visible in the diff; if the function's opening only shallow-copies, nested nodes (items, properties) are still shared.
Suggested fix: Deep-clone at sanitizeGemini entry (structuredClone or rebuild objects during recursion) so ProviderTransform.schema is pure; at minimum clone nodes before entering the split branch.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

sanitizeGemini rewrites nodes in place (pre-existing pattern: result.items.type = "string", delete result.properties), but the new split is far more destructive: it deletes properties/required/items and installs anyOf/nullable on the caller's schema object. If a tool's inputSchema object is registered once and reused across requests or models (the new tests build fresh literals, so they won't catch it), the first Gemini request corrupts the shared schema and a subsequent non-Gemini model receives the Gemini-mangled anyOf/nullable shape. No clone is visible in the diff; if the function's opening only shallow-copies, nested nodes (items, properties) are still shared.

            delete result.type
            result.anyOf = nonNull.map((entry: unknown) => ({
              type: entry,
              ...(entry === "object" && result.properties !== undefined ? { properties: result.properties } : {}),
              ...(entry === "object" && result.required !== undefined ? { required: result.required } : {}),
              ...(entry === "array" && result.items !== undefined ? { items: result.items } : {}),
            }))
            // Member schemas belong to their typed branch, not beside anyOf.
            delete result.properties
            delete result.required
            delete result.items

Comment thread packages/cli/src/provider/transform.ts Outdated
result.type = nonNull[0]
} else {
delete result.type
result.anyOf = nonNull.map((entry: unknown) => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Split strands non-copied constraints beside anyOf.

Suggested change
result.anyOf = nonNull.map((entry: unknown) => ({
Extend the branch copy to carry the relevant constraint families (additionalProperties/minProperties/propertyNames object branch; minItems/maxItems/uniqueItems array branch), or document the deliberate drop next to the comment.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #119, packages/cli/src/provider/transform.ts:1035-1039):

Problem: Split strands non-copied constraints beside anyOf
Detail: The split copies only properties/required (object branch) and items (array branch) off the parent. Other constraints stay beside anyOf on the now-typeless parent — additionalProperties, patternProperties, propertyNames, minProperties, minItems/maxItems/uniqueItems, parent-side enum — contradicting the new comment "Member schemas belong to their typed branch, not beside anyOf". Gemini's Schema has no field for the object-side keywords (silently dropped, so the constraint is expressed nowhere), while array-side ones (minItems etc.) are representable on the typed branch the split just created. E.g. `{type:["object","null"], additionalProperties:false, minProperties:1}` ends as `{anyOf:[{type:"object",...}], nullable:true, additionalProperties:false, minProperties:1}` with the constraints stranded.
Suggested fix: Extend the branch copy to carry the relevant constraint families (additionalProperties/minProperties/propertyNames → object branch; minItems/maxItems/uniqueItems → array branch), or document the deliberate drop next to the comment.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The split copies only properties/required (object branch) and items (array branch) off the parent. Other constraints stay beside anyOf on the now-typeless parent — additionalProperties, patternProperties, propertyNames, minProperties, minItems/maxItems/uniqueItems, parent-side enum — contradicting the new comment "Member schemas belong to their typed branch, not beside anyOf". Gemini's Schema has no field for the object-side keywords (silently dropped, so the constraint is expressed nowhere), while array-side ones (minItems etc.) are representable on the typed branch the split just created. E.g. {type:["object","null"], additionalProperties:false, minProperties:1} ends as {anyOf:[{type:"object",...}], nullable:true, additionalProperties:false, minProperties:1} with the constraints stranded.

            delete result.type
            result.anyOf = nonNull.map((entry: unknown) => ({
              type: entry,
              ...(entry === "object" && result.properties !== undefined ? { properties: result.properties } : {}),
              ...(entry === "object" && result.required !== undefined ? { required: result.required } : {}),
              ...(entry === "array" && result.items !== undefined ? { items: result.items } : {}),
            }))
            // Member schemas belong to their typed branch, not beside anyOf.

Comment thread packages/cli/src/provider/transform.ts Outdated
isPlainObject(node) && (Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf))
// Default only unconstrained/annotation-only items. An allowlist of
// constraint keywords would miss extensions and silently change intent.
const annotations = new Set([

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

annotations Set rebuilt on every schema() call.

Suggested change
const annotations = new Set([
Move `const annotations = new Set([...])` (and optionally isPlainObject/hasCombiner) out of the `if` block to module scope near the top of the namespace; keep only `native` and `sanitizeGemini` inside the guard.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #119, packages/cli/src/provider/transform.ts:950-963):

Problem: annotations Set rebuilt on every schema() call
Detail: The `annotations` Set (and the `isPlainObject`/`hasCombiner` helpers) are pure constants but are declared inside the `if (model.providerID === "google" || ...)` guard, so they are re-allocated on every ProviderTransform.schema call for Gemini models. Hoisting them to module/namespace scope avoids the repeated allocation on the hot path.
Suggested fix: Move `const annotations = new Set([...])` (and optionally isPlainObject/hasCombiner) out of the `if` block to module scope near the top of the namespace; keep only `native` and `sanitizeGemini` inside the guard.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The annotations Set (and the isPlainObject/hasCombiner helpers) are pure constants but are declared inside the if (model.providerID === "google" || ...) guard, so they are re-allocated on every ProviderTransform.schema call for Gemini models. Hoisting them to module/namespace scope avoids the repeated allocation on the hot path.

      // Default only unconstrained/annotation-only items. An allowlist of
      // constraint keywords would miss extensions and silently change intent.
      const annotations = new Set([
        "$schema",
        "$id",

Comment thread packages/cli/src/provider/transform.ts Outdated
if (
isPlainObject(result.items) &&
Object.keys(result.items).every(
(key) => annotations.has(key) || (key === "enum" && Array.isArray(result.items.enum)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-array enum leaves items without any type.

Suggested change
(key) => annotations.has(key) || (key === "enum" && Array.isArray(result.items.enum)),
Handle the non-array `enum` case explicitly (drop it, or treat the node as unconstrained and apply the string default) rather than letting it silently fall through the predicate.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #119, packages/cli/src/provider/transform.ts:1008-1013):

Problem: Non-array enum leaves items without any type
Detail: The items-default predicate treats `enum` as annotation-like only when it is array-valued; a non-array `enum` (invalid JSON Schema, but reachable from hand-written jsonSchema() tool input) makes every() false and leaves the items node with no type at all, where pre-PR it received type:"string". Constraint-only items staying type-less is deliberate and test-pinned, but this particular edge silently produces a type-less leaf from near-invalid input instead of normalizing or rejecting it.
Suggested fix: Handle the non-array `enum` case explicitly (drop it, or treat the node as unconstrained and apply the string default) rather than letting it silently fall through the predicate.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The items-default predicate treats enum as annotation-like only when it is array-valued; a non-array enum (invalid JSON Schema, but reachable from hand-written jsonSchema() tool input) makes every() false and leaves the items node with no type at all, where pre-PR it received type:"string". Constraint-only items staying type-less is deliberate and test-pinned, but this particular edge silently produces a type-less leaf from near-invalid input instead of normalizing or rejecting it.

          if (
            isPlainObject(result.items) &&
            Object.keys(result.items).every(
              (key) => annotations.has(key) || (key === "enum" && Array.isArray(result.items.enum)),
            )
          ) {


test("strips object-only fields before splitting primitive nullable types", () => {
const schema = { type: ["string", "null"], properties: { unused: { type: "string" } }, required: ["unused"] } as any
expect(ProviderTransform.schema(geminiModel, schema) as unknown).toEqual({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixed as unknown/as any casts in new tests.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #119, packages/cli/test/provider/transform.test.ts:586):

Problem: Mixed as unknown/as any casts in new tests
Detail: Four assertions in the new describe block cast the result with `as unknown` before toEqual (lines 586, 598, 612, 635), while sibling tests in the same block and the rest of the file use `as any`. The `as unknown` cast is a no-op for toEqual and breaks the file's prevailing convention.
Suggested fix: Drop the cast entirely or use `as any` to match the surrounding tests.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Four assertions in the new describe block cast the result with as unknown before toEqual (lines 586, 598, 612, 635), while sibling tests in the same block and the rest of the file use as any. The as unknown cast is a no-op for toEqual and breaks the file's prevailing convention.

  test("strips object-only fields before splitting primitive nullable types", () => {
    const schema = { type: ["string", "null"], properties: { unused: { type: "string" } }, required: ["unused"] } as any
    expect(ProviderTransform.schema(geminiModel, schema) as unknown).toEqual({
      anyOf: [{ type: "string" }],
      nullable: true,
    })

@aictrl-dev

aictrl-dev Bot commented Sep 14, 2026

Copy link
Copy Markdown

Code review

Verdict: Looks good — only minor / nit comments below. · 🔴 0 · 🟠 0 · 🟡 5 · ⚪ 3 · 0/8 resolved

  • packages/cli/src/provider/transform.ts:950-963 — annotations Set rebuilt on every schema() call
  • 🟡 packages/cli/src/provider/transform.ts:990-992 — Empty type array throws even for native adapters
  • packages/cli/src/provider/transform.ts:1008-1013 — Non-array enum leaves items without any type
  • 🟡 packages/cli/src/provider/transform.ts:1026 — Combiner nodes keep raw type arrays on non-native
  • 🟡 packages/cli/src/provider/transform.ts:1030 — Null-only type array emits bare type:"null"
  • 🟡 packages/cli/src/provider/transform.ts:1035-1039 — Split strands non-copied constraints beside anyOf
  • 🟡 packages/cli/src/provider/transform.ts:1042-1044 — Split mutates caller's schema object in place
  • packages/cli/test/provider/transform.test.ts:586 — Mixed as unknown/as any casts in new tests
🤖 Fix all 8 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #119 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/provider/transform.ts:950-963 — annotations Set rebuilt on every schema() call
   Detail: The `annotations` Set (and the `isPlainObject`/`hasCombiner` helpers) are pure constants but are declared inside the `if (model.providerID === "google" || ...)` guard, so they are re-allocated on every ProviderTransform.schema call for Gemini models. Hoisting them to module/namespace scope avoids the repeated allocation on the hot path.
   Suggested fix: Move `const annotations = new Set([...])` (and optionally isPlainObject/hasCombiner) out of the `if` block to module scope near the top of the namespace; keep only `native` and `sanitizeGemini` inside the guard.
2. packages/cli/src/provider/transform.ts:990-992 — Empty type array throws even for native adapters
   Detail: The hard throw on `type: []` fires for every Gemini path, including native @ai-sdk/google / google-vertex models that this same PR otherwise defers to — the `!native` guard protects only the split (1026), not the throw (990). One malformed nested property anywhere in any tool schema now makes ProviderTransform.schema throw and kills the whole request for native google/vertex users, where pre-PR the schema passed through to the adapter. The test ("rejects an empty type array before %s converts it") shows this fail-fast is deliberate, but it converts a local, single-tool schema defect into a total request failure even on paths the PR explicitly leaves to the native adapter.
   Suggested fix: Either skip the throw when `native` (let the pinned adapter decide), or coerce `type: []` to an unconstrained node (`delete result.type`) instead of throwing; keep the loud failure only for non-native transports if desired.
3. packages/cli/src/provider/transform.ts:1008-1013 — Non-array enum leaves items without any type
   Detail: The items-default predicate treats `enum` as annotation-like only when it is array-valued; a non-array `enum` (invalid JSON Schema, but reachable from hand-written jsonSchema() tool input) makes every() false and leaves the items node with no type at all, where pre-PR it received type:"string". Constraint-only items staying type-less is deliberate and test-pinned, but this particular edge silently produces a type-less leaf from near-invalid input instead of normalizing or rejecting it.
   Suggested fix: Handle the non-array `enum` case explicitly (drop it, or treat the node as unconstrained and apply the string default) rather than letting it silently fall through the predicate.
4. packages/cli/src/provider/transform.ts:1026 — Combiner nodes keep raw type arrays on non-native
   Detail: Nodes carrying both a type array and a pre-existing anyOf/oneOf/allOf skip the split (`!composed` at line 1026) and all hygiene steps (`!composed` at 997/1001/1018) on non-native transports, so e.g. `{type:["object","null"], anyOf:[...]}` still reaches the Gemini-side translation with a raw type array — the exact shape this PR exists to eliminate for non-native transports. The "preserves a pre-existing %s on a type-array node" test pins the passthrough for github-copilot. Skipping is a deliberate tradeoff (splitting would create competing nested combiners), but it leaves a known-bad wire shape unhandled on the non-native path.
   Suggested fix: When `!native && composed && Array.isArray(result.type)`, consider dropping the redundant type array (expressing nullability via `nullable: true`) instead of passing it through, since the existing combiner already carries the union.
5. packages/cli/src/provider/transform.ts:1030 — Null-only type array emits bare type:"null"
   Detail: When a type array contains only "null" (e.g. `type: ["null"]`), the non-native split sets `result.type = "null"`. Gemini's Schema.type enum has no NULL value — nullability is expressed via `nullable`, which is exactly what every other branch in this same block does (anyOf + `nullable: true`). On the non-native transports this PR targets precisely because their translation layer does not normalize, a bare `{type:"null"}` node is invalid on the wire (or coerced to TYPE_UNSPECIFIED). Not a regression (pre-PR sent `["null"]` raw, equally invalid), but the rewrite should match the convention it establishes everywhere else. The "collapses a null-only type array" test pins the current shape.
   Suggested fix: Emit `result.nullable = true` and delete `result.type` (leaving an unconstrained nullable schema) instead of `result.type = "null"`; update the `["null"]` test expectation accordingly.
6. packages/cli/src/provider/transform.ts:1035-1039 — Split strands non-copied constraints beside anyOf
   Detail: The split copies only properties/required (object branch) and items (array branch) off the parent. Other constraints stay beside anyOf on the now-typeless parent — additionalProperties, patternProperties, propertyNames, minProperties, minItems/maxItems/uniqueItems, parent-side enum — contradicting the new comment "Member schemas belong to their typed branch, not beside anyOf". Gemini's Schema has no field for the object-side keywords (silently dropped, so the constraint is expressed nowhere), while array-side ones (minItems etc.) are representable on the typed branch the split just created. E.g. `{type:["object","null"], additionalProperties:false, minProperties:1}` ends as `{anyOf:[{type:"object",...}], nullable:true, additionalProperties:false, minProperties:1}` with the constraints stranded.
   Suggested fix: Extend the branch copy to carry the relevant constraint families (additionalProperties/minProperties/propertyNames → object branch; minItems/maxItems/uniqueItems → array branch), or document the deliberate drop next to the comment.
7. packages/cli/src/provider/transform.ts:1042-1044 — Split mutates caller's schema object in place
   Detail: sanitizeGemini rewrites nodes in place (pre-existing pattern: `result.items.type = "string"`, `delete result.properties`), but the new split is far more destructive: it deletes properties/required/items and installs anyOf/nullable on the caller's schema object. If a tool's inputSchema object is registered once and reused across requests or models (the new tests build fresh literals, so they won't catch it), the first Gemini request corrupts the shared schema and a subsequent non-Gemini model receives the Gemini-mangled anyOf/nullable shape. No clone is visible in the diff; if the function's opening only shallow-copies, nested nodes (items, properties) are still shared.
   Suggested fix: Deep-clone at sanitizeGemini entry (structuredClone or rebuild objects during recursion) so ProviderTransform.schema is pure; at minimum clone nodes before entering the split branch.
8. packages/cli/test/provider/transform.test.ts:586 — Mixed as unknown/as any casts in new tests
   Detail: Four assertions in the new describe block cast the result with `as unknown` before toEqual (lines 586, 598, 612, 635), while sibling tests in the same block and the rest of the file use `as any`. The `as unknown` cast is a no-op for toEqual and breaks the file's prevailing convention.
   Suggested fix: Drop the cast entirely or use `as any` to match the surrounding tests.
📋 Out-of-diff findings (8)
Sev Location Finding
packages/cli/src/provider/transform.ts:950-963 annotations Set rebuilt on every schema() call
🟡 packages/cli/src/provider/transform.ts:990-992 Empty type array throws even for native adapters
packages/cli/src/provider/transform.ts:1008-1013 Non-array enum leaves items without any type
🟡 packages/cli/src/provider/transform.ts:1026 Combiner nodes keep raw type arrays on non-native
🟡 packages/cli/src/provider/transform.ts:1030 Null-only type array emits bare type:"null"
🟡 packages/cli/src/provider/transform.ts:1035-1039 Split strands non-copied constraints beside anyOf
🟡 packages/cli/src/provider/transform.ts:1042-1044 Split mutates caller's schema object in place
packages/cli/test/provider/transform.test.ts:586 Mixed as unknown/as any casts in new tests

Reviewed 3 files · 0 inline · view all 8 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov

Copy link
Copy Markdown
Contributor Author

Review response — PR #119

Verified the eight findings from the review of aff0dd33; pushed fixes in c56567746bb2ec37c7b0ed216b7e250c4a2019b5. Verdicts: 4 FIX, 3 FALSE/IGNORE, 1 TRUE/DEFER.

Issues addressed (pushed to this PR)

  • Composed nodes retained type arrayspackages/cli/src/provider/transform.ts: normalize non-native unions while preserving existing combiners on each typed branch. Null retains universal constraints instead of bypassing them via nullable (commit c56567746bb2ec37c7b0ed216b7e250c4a2019b5).
  • Empty type arrays threw on native adapterspackages/cli/src/provider/transform.ts: scope the fail-fast check to non-native transports; native Google and Vertex conversion remains delegated (commit c56567746bb2ec37c7b0ed216b7e250c4a2019b5).
  • Constraints remained outside typed branchespackages/cli/src/provider/transform.ts: distribute type-specific constraints to matching branches and universal constraints to every branch; preserve annotations and definition locations (commit c56567746bb2ec37c7b0ed216b7e250c4a2019b5).
  • Repeated constant/helper allocationpackages/cli/src/provider/transform.ts: hoist annotation and keyword lookup tables and pure helpers to module scope (commit c56567746bb2ec37c7b0ed216b7e250c4a2019b5).

Review claims verified false (no change needed)

  • “Gemini's Schema.type enum has no NULL value” — the Google Schema Type reference explicitly includes NULL. The pinned Google adapter also emits type: "null"; the session request-capture test now checks this. Converting null-only to nullable string would admit values excluded by the original schema.
  • “Split mutates caller's schema object in place”sanitizeGemini allocates a fresh object and recursively maps arrays before applying edits. A new frozen-schema regression exercises repeated transforms, native reuse, and mutation of returned results while proving the original and another result stay unchanged. An additional deep clone would duplicate the existing traversal.
  • “as unknown is a no-op for toEqual” — it changes the generic assertion type from JSONSchema7, permitting the Gemini-specific nullable extension in expected values. Without that widening, TypeScript reports TS2769; unknown avoids any while retaining complete runtime equality assertions.

Not addressed here

  • Non-array enum input remains invalid — this is already invalid JSON Schema. Adding type: "string" cannot repair its non-array enum; removing or coercing the constraint could change user intent. A regression preserves the behavior. General validation of hand-written schema input remains outside this normalization patch.

Validation: 161 tests passed, 0 failed, 303 assertions across provider transform and session LLM tests; CLI typecheck, Prettier, and git diff --check passed. An independent Python jsonschema check compared eight original/transformed schema pairs over 27 values (216 matching acceptance decisions), translating Gemini nullable to its JSON Schema equivalent for the comparison. The Google test captures adapter-generated HTTP payloads locally; it does not claim live service validation.

MCP verdict persistence is unavailable: matched 0, written 0, verified 0, failed 0, unrecorded 8. The GitHub sidecar records all eight reviewed findings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant