Skip to content

feat: preserve safe provider termination metadata - #114

Open
byapparov wants to merge 4 commits into
mainfrom
backport/issue-109
Open

byapparov wants to merge 4 commits into
mainfrom
backport/issue-109

Conversation

@byapparov

Copy link
Copy Markdown
Contributor

Relates to #109

Intent

Normalized provider finish reasons currently lose the safe context needed to distinguish an observed raw reason from an unavailable diagnostic. This change carries bounded termination metadata through the CLI event path without collecting prompt, tool, or full response content.

Expected Impact on Users

Operators and downstream consumers can correlate normalized and available raw termination reasons with provider/model/request identity in step_finish NDJSON events. Free-form provider diagnostic text remains suppressed by default.

Expected Outcomes

  • Pinned Google and Vertex adapter streams preserve normalized and allowlisted raw finish reasons.
  • Request identity and diagnostic availability are explicit, bounded, and redaction-safe.
  • Older or normalized-only adapters continue to report raw details as unavailable.

Implementation

  • Enable internal raw chunks in a middleware, discard the raw events before downstream consumers see them, and retain only allowlisted termination fields.
  • Add optional step_finish.termination storage, event serialization, SDK types, and availability/redaction documentation.

Scope Caveat

This is the safe first slice of #109. Free-form finish-message text and broader provider coverage remain intentionally out of scope until their privacy and availability policy is approved.

Test Plan

  • Actual pinned Google and Vertex adapter fixtures verify raw/normalized reason propagation into headless NDJSON.
  • Tests cover normalized-only fallback, oversize/redaction flags, request correlation, and unchanged adapter exceptions.

Verification

  • 39 focused and adjacent tests passed.
  • CLI typecheck, formatting, and diff checks passed.

Risks and Rollout

The new field is optional and additive. Raw chunks remain internal to middleware; no database migration or opt-in is required.

@byapparov byapparov added this to the Enterprise Observability milestone Sep 14, 2026
@github-actions

Copy link
Copy Markdown

Review

Reviewed the middleware, event/storage path, tests, and cross-checked assumptions against the pinned @ai-sdk/provider@2.0.1 types. No blocking issues found. The privacy design is sound: raw reasons are allowlist-only, request IDs are charset-bounded with credential-prefix suppression, free-form diagnostics are always redacted, and raw chunks are dropped inside the middleware before any stream consumer sees them. I verified includeRawChunks and the {type: "raw"} stream part exist in the pinned provider spec, doStream's response.headers is a plain record (so bracket access is correct), Session.updatePart validates through the extended StepFinishPart schema so termination persists and reaches the step_finish event, retries re-invoke wrapStream (fresh state per attempt), and middleware ordering in llm.ts is safe either way (only one wrapStream; the prompt transform mutates args.params in place and includeRawChunks survives in both composition orders).

Minor, non-blocking notes:

  1. Stray EOF importpackages/cli/src/session/message-v2.ts:902 appends import { ProviderTermination } ... after the namespace's closing brace. It works via ESM hoisting, but it is the only file in src/ with a bottom-of-file import and looks like an auto-import artifact. Please move it to the top import block.

  2. Diagnostic capture couplingdiagnostic is only recorded from a raw chunk whose candidates[0] also has a non-null finishReason (termination.ts:80-87). If finishMessage ever arrives on a separate chunk from finishReason, the diagnostic reports unavailable despite being observed. Also, candidates[0] is array order, not the entry with index: 0. Low impact for a presence/size-only field, but worth a comment or a small loosening.

  3. Raw-chunk dropping is global — the transform drops type: "raw" chunks for every provider, while includeRawChunks is only requested for Google adapters. That is currently an invariant, not a bug (nothing else requests raw chunks today, including the bundled copilot adapters which gate on options.includeRawChunks), but the silent drop would also swallow raw chunks if a future caller requested them. A one-line comment stating this invariant would help future readers.

  4. truncated semantics edge — in field() (termination.ts:48), a value that both exceeds the limit and fails the allowlist reports truncated: true, conflating "redacted for content" with "suppressed for size". Consistent with the EVENTS.md wording ("an oversized value was entirely suppressed"), just flagging the ambiguity since it also applies to credential-shaped request IDs.

Test coverage is good — the adapter-level tests exercise real Google/Vertex code paths (not mocks), and the headless e2e asserts both propagation and non-leakage of fixture secrets. Docs in EVENTS.md match the implemented behavior.

Reviewed SHA: 206c0e4

const result = await doStream()
let rawReason = field(undefined, () => false)
let diagnostic = field(undefined, () => false)
const headers = result.response?.headers

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 result.response?.headers never exists in v2; requestID dead.

--- a/packages/cli/src/provider/termination.ts
+++ b/packages/cli/src/provider/termination.ts
@@ -61,7 +61,7 @@
         const result = await doStream()
         let rawReason = field(undefined, () => false)
         let diagnostic = field(undefined, () => false)
-        const headers = result.response?.headers
+        const headers = result.responseHeaders
         const requestID = field(
           headers?.["x-request-id"] ?? headers?.["x-goog-request-id"],
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #114, packages/cli/src/provider/termination.ts:64-68):

Problem: result.response?.headers never exists in v2; requestID dead
Detail: The middleware declares `LanguageModelV2Middleware` from `@ai-sdk/provider` (v2 / AI SDK 5), where `doStream()` resolves `LanguageModelV2StreamResult = { stream, request, responseHeaders? }` — there is no `response` wrapper property. So `result.response?.headers` is always `undefined`, `requestID` is permanently `{status:"unavailable"}`, and the feature's request-ID capture never works. The PR's own tests assert `requestID: {status:"available", value:"req_fixture"|"req_123"}` (test/cli/run-termination.test.ts, test/provider/termination.test.ts), which fail; it is also a TypeScript error (`Property 'response' does not exist`), so typecheck should flag it.
Suggested fix: Replace `const headers = result.response?.headers` with `const headers = result.responseHeaders` (the v2 `LanguageModelV2StreamResult` exposes response headers directly as `responseHeaders`).

Suggested patch:
--- a/packages/cli/src/provider/termination.ts
+++ b/packages/cli/src/provider/termination.ts
@@ -61,7 +61,7 @@
         const result = await doStream()
         let rawReason = field(undefined, () => false)
         let diagnostic = field(undefined, () => false)
-        const headers = result.response?.headers
+        const headers = result.responseHeaders
         const requestID = field(
           headers?.["x-request-id"] ?? headers?.["x-goog-request-id"],


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 middleware declares LanguageModelV2Middleware from @ai-sdk/provider (v2 / AI SDK 5), where doStream() resolves LanguageModelV2StreamResult = { stream, request, responseHeaders? } — there is no response wrapper property. So result.response?.headers is always undefined, requestID is permanently {status:"unavailable"}, and the feature's request-ID capture never works. The PR's own tests assert requestID: {status:"available", value:"req_fixture"|"req_123"} (test/cli/run-termination.test.ts, test/provider/termination.test.ts), which fail; it is also a TypeScript error (Property 'response' does not exist), so typecheck should flag it.

      async wrapStream({ doStream }) {
        const result = await doStream()
        let rawReason = field(undefined, () => false)
        let diagnostic = field(undefined, () => false)
        const headers = result.response?.headers
        const requestID = field(
          headers?.["x-request-id"] ?? headers?.["x-goog-request-id"],
          (value) => /^[a-zA-Z0-9_-]+$/.test(value) && !/^(sk-|gh[pousr]_|github_pat_|AIza|AKIA|ASIA)/.test(value),
        )
        return {

Comment thread packages/sdk/src/gen/types.gen.ts Outdated
messageID: string
type: "step-finish"
reason: string
termination?: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Hand-edited generated SDK types; OpenAPI mirror not updated.

Suggested change
termination?: {
Source the field from the zod schema (already added in message-v2.ts with `.meta({ ref: "ProviderTermination" })`) and run the OpenAPI + SDK codegen steps so docs/architecture/openapi.yaml and packages/sdk/src/gen/types.gen.ts are regenerated together instead of hand-editing generated output.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #114, packages/sdk/src/gen/types.gen.ts:321-328):

Problem: Hand-edited generated SDK types; OpenAPI mirror not updated
Detail: The repo generates its OpenAPI contract (scripts/generate-openapi.ts → docs/architecture/openapi.yaml) and packages/sdk/src/gen/types.gen.ts is generated output of that contract. This PR hand-edits types.gen.ts to add `StepFinishPart.termination` but touches neither openapi.yaml nor the generator inputs, so the next regeneration clobbers the hand edit and the published OpenAPI contract never declares the new field — SDK types and API schema drift apart.
Suggested fix: Source the field from the zod schema (already added in message-v2.ts with `.meta({ ref: "ProviderTermination" })`) and run the OpenAPI + SDK codegen steps so docs/architecture/openapi.yaml and packages/sdk/src/gen/types.gen.ts are regenerated together instead of hand-editing generated output.

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 repo generates its OpenAPI contract (scripts/generate-openapi.ts → docs/architecture/openapi.yaml) and packages/sdk/src/gen/types.gen.ts is generated output of that contract. This PR hand-edits types.gen.ts to add StepFinishPart.termination but touches neither openapi.yaml nor the generator inputs, so the next regeneration clobbers the hand edit and the published OpenAPI contract never declares the new field — SDK types and API schema drift apart.

Comment thread EVENTS.md

### `step_start` / `step_finish`

`step_finish.part.termination` is an optional additive diagnostic object. Its

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Termination block splits heading from section intro sentence.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #114, EVENTS.md:298-354):

Problem: Termination block splits heading from section intro sentence
Detail: The 57-line termination subsection is inserted directly under `### step_start / step_finish`, pushing the section's own one-line description "Emitted at step boundaries during multi-step tool use." to after the JSON example and policy paragraphs. Readers now hit termination-specific detail before being told what the events are, inverting the intro-then-detail structure used by surrounding sections.
Suggested fix: Move the insertion so the intro sentence stays immediately after the heading — either place the termination content after "Emitted at step boundaries during multi-step tool use." or under its own `#### step_finish.part.termination` subheading.

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 57-line termination subsection is inserted directly under ### step_start / step_finish, pushing the section's own one-line description "Emitted at step boundaries during multi-step tool use." to after the JSON example and policy paragraphs. Readers now hit termination-specific detail before being told what the events are, inverting the intro-then-detail structure used by surrounding sections.

const headers = result.response?.headers
const requestID = field(
headers?.["x-request-id"] ?? headers?.["x-goog-request-id"],
(value) => /^[a-zA-Z0-9_-]+$/.test(value) && !/^(sk-|gh[pousr]_|github_pat_|AIza|AKIA|ASIA)/.test(value),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 requestID guard admits xoxb-/glpat-/npm_ tokens.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #114, packages/cli/src/provider/termination.ts:67):

Problem: requestID guard admits xoxb-/glpat-/npm_ tokens
Detail: The only secret guard on persisted request IDs is the charset `^[a-zA-Z0-9_-]+$` plus a prefix denylist (sk-, gh[pousr]_, github_pat_, AIza, AKIA, ASIA). Several real credential formats satisfy both and stay under the 128-char cap — e.g. Slack `xoxb-…`/`xoxp-…`, `glpat-…` GitLab tokens, `npm_…` registry tokens, and prefix-less hex/base64url keys — so a provider or intermediary echoing a secret into `x-request-id`/`x-goog-request-id` would have it persisted verbatim into session storage and headless NDJSON output. Defense-in-depth (requires a misbehaving provider, which this PR explicitly treats as untrusted), but EVENTS.md presents the denylist as the secret guard.
Suggested fix: Tighten toward an allowlist of provider-expected shapes instead of enumerating secret prefixes: e.g. require length ≤ 64 and `^[a-zA-Z0-9]{8,64}$` (Google-style IDs use alphanumerics only), or persist a non-reversible truncated form (first 8 chars + length) when the header does not match the known provider 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 only secret guard on persisted request IDs is the charset ^[a-zA-Z0-9_-]+$ plus a prefix denylist (sk-, gh[pousr], github_pat, AIza, AKIA, ASIA). Several real credential formats satisfy both and stay under the 128-char cap — e.g. Slack xoxb-…/xoxp-…, glpat-… GitLab tokens, npm_… registry tokens, and prefix-less hex/base64url keys — so a provider or intermediary echoing a secret into x-request-id/x-goog-request-id would have it persisted verbatim into session storage and headless NDJSON output. Defense-in-depth (requires a misbehaving provider, which this PR explicitly treats as untrusted), but EVENTS.md presents the denylist as the secret guard.

Comment thread packages/cli/src/session/message-v2.ts Outdated
}
}
}
import { ProviderTermination } from "@/provider/termination"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Import appended at EOF instead of top import block.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #114, packages/cli/src/session/message-v2.ts:902):

Problem: Import appended at EOF instead of top import block
Detail: The new `import { ProviderTermination } from "@/provider/termination"` is appended at line 902, after the closing brace of the `MessageV2` namespace, while every other import in this file (and the sibling changes in llm.ts/processor.ts in this same PR) sits in the top import block. ES-module hoisting makes it work, but it breaks the file's import convention, trips import-ordering lint (import/first), and risks a duplicate import when someone later adds it at the top.
Suggested fix: Move the import into the top import block, alphabetically between the existing `@/provider/error` and `@/provider/transform` imports, and delete the trailing line 902.

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 import { ProviderTermination } from "@/provider/termination" is appended at line 902, after the closing brace of the MessageV2 namespace, while every other import in this file (and the sibling changes in llm.ts/processor.ts in this same PR) sits in the top import block. ES-module hoisting makes it work, but it breaks the file's import convention, trips import-ordering lint (import/first), and risks a duplicate import when someone later adds it at the top.

      }
    }
  }
}
import { ProviderTermination } from "@/provider/termination"

@aictrl-dev

aictrl-dev Bot commented Sep 14, 2026

Copy link
Copy Markdown

Code review

Verdict: Do not merge — 1 blocker(s) must be fixed. · 🔴 1 · 🟠 1 · 🟡 3 · ⚪ 0 · 0/5 resolved

  • 🟡 EVENTS.md:298-354 — Termination block splits heading from section intro sentence
  • 🔴 packages/cli/src/provider/termination.ts:64-68 — result.response?.headers never exists in v2; requestID dead
  • 🟡 packages/cli/src/provider/termination.ts:67 — requestID guard admits xoxb-/glpat-/npm_ tokens
  • 🟡 packages/cli/src/session/message-v2.ts:902 — Import appended at EOF instead of top import block
  • 🟠 packages/sdk/src/gen/types.gen.ts:321-328 — Hand-edited generated SDK types; OpenAPI mirror not updated
🤖 Fix all 5 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #114 (head branch).
Run the relevant tests/linters after each change.

1. EVENTS.md:298-354 — Termination block splits heading from section intro sentence
   Detail: The 57-line termination subsection is inserted directly under `### step_start / step_finish`, pushing the section's own one-line description "Emitted at step boundaries during multi-step tool use." to after the JSON example and policy paragraphs. Readers now hit termination-specific detail before being told what the events are, inverting the intro-then-detail structure used by surrounding sections.
   Suggested fix: Move the insertion so the intro sentence stays immediately after the heading — either place the termination content after "Emitted at step boundaries during multi-step tool use." or under its own `#### step_finish.part.termination` subheading.
2. packages/cli/src/provider/termination.ts:64-68 — result.response?.headers never exists in v2; requestID dead
   Detail: The middleware declares `LanguageModelV2Middleware` from `@ai-sdk/provider` (v2 / AI SDK 5), where `doStream()` resolves `LanguageModelV2StreamResult = { stream, request, responseHeaders? }` — there is no `response` wrapper property. So `result.response?.headers` is always `undefined`, `requestID` is permanently `{status:"unavailable"}`, and the feature's request-ID capture never works. The PR's own tests assert `requestID: {status:"available", value:"req_fixture"|"req_123"}` (test/cli/run-termination.test.ts, test/provider/termination.test.ts), which fail; it is also a TypeScript error (`Property 'response' does not exist`), so typecheck should flag it.
   Suggested fix: Replace `const headers = result.response?.headers` with `const headers = result.responseHeaders` (the v2 `LanguageModelV2StreamResult` exposes response headers directly as `responseHeaders`).
3. packages/cli/src/provider/termination.ts:67 — requestID guard admits xoxb-/glpat-/npm_ tokens
   Detail: The only secret guard on persisted request IDs is the charset `^[a-zA-Z0-9_-]+$` plus a prefix denylist (sk-, gh[pousr]_, github_pat_, AIza, AKIA, ASIA). Several real credential formats satisfy both and stay under the 128-char cap — e.g. Slack `xoxb-…`/`xoxp-…`, `glpat-…` GitLab tokens, `npm_…` registry tokens, and prefix-less hex/base64url keys — so a provider or intermediary echoing a secret into `x-request-id`/`x-goog-request-id` would have it persisted verbatim into session storage and headless NDJSON output. Defense-in-depth (requires a misbehaving provider, which this PR explicitly treats as untrusted), but EVENTS.md presents the denylist as the secret guard.
   Suggested fix: Tighten toward an allowlist of provider-expected shapes instead of enumerating secret prefixes: e.g. require length ≤ 64 and `^[a-zA-Z0-9]{8,64}$` (Google-style IDs use alphanumerics only), or persist a non-reversible truncated form (first 8 chars + length) when the header does not match the known provider shape.
4. packages/cli/src/session/message-v2.ts:902 — Import appended at EOF instead of top import block
   Detail: The new `import { ProviderTermination } from "@/provider/termination"` is appended at line 902, after the closing brace of the `MessageV2` namespace, while every other import in this file (and the sibling changes in llm.ts/processor.ts in this same PR) sits in the top import block. ES-module hoisting makes it work, but it breaks the file's import convention, trips import-ordering lint (import/first), and risks a duplicate import when someone later adds it at the top.
   Suggested fix: Move the import into the top import block, alphabetically between the existing `@/provider/error` and `@/provider/transform` imports, and delete the trailing line 902.
5. packages/sdk/src/gen/types.gen.ts:321-328 — Hand-edited generated SDK types; OpenAPI mirror not updated
   Detail: The repo generates its OpenAPI contract (scripts/generate-openapi.ts → docs/architecture/openapi.yaml) and packages/sdk/src/gen/types.gen.ts is generated output of that contract. This PR hand-edits types.gen.ts to add `StepFinishPart.termination` but touches neither openapi.yaml nor the generator inputs, so the next regeneration clobbers the hand edit and the published OpenAPI contract never declares the new field — SDK types and API schema drift apart.
   Suggested fix: Source the field from the zod schema (already added in message-v2.ts with `.meta({ ref: "ProviderTermination" })`) and run the OpenAPI + SDK codegen steps so docs/architecture/openapi.yaml and packages/sdk/src/gen/types.gen.ts are regenerated together instead of hand-editing generated output.
📋 Out-of-diff findings (5)
Sev Location Finding
🟡 EVENTS.md:298-354 Termination block splits heading from section intro sentence
🔴 packages/cli/src/provider/termination.ts:64-68 result.response?.headers never exists in v2; requestID dead
🟡 packages/cli/src/provider/termination.ts:67 requestID guard admits xoxb-/glpat-/npm_ tokens
🟡 packages/cli/src/session/message-v2.ts:902 Import appended at EOF instead of top import block
🟠 packages/sdk/src/gen/types.gen.ts:321-328 Hand-edited generated SDK types; OpenAPI mirror not updated

Reviewed 8 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 #114

Verified all five findings against the pinned dependencies and actual repository codegen workflow; fixed four and rejected one false API-shape claim.

Issues addressed (pushed to this PR)

  • Generated SDK/OpenAPI contractpackages/cli/src/cli/cmd/generate.ts and packages/sdk/script/build.ts: emit StepFinishPart and ProviderTermination from runtime Zod schemas, run the actual SDK generator, export canonical v2 types, and maintain the legacy StepFinishPart alias during codegen. The review’s named generator/YAML paths do not exist in this repository; its underlying reproducibility concern is addressed (commit be1bcb4b8c).
  • Termination documentation orderingEVENTS.md: restored the introductory sentence/basic examples before the detailed termination subsection (commit 52dcaebf74).
  • Request-ID credential disclosurepackages/cli/src/provider/termination.ts: suppress all raw request-ID values, including ordinary IDs. Preserve only presence/redaction and oversize state; do not rely on credential denylists. Added token-format and prefixless-value regressions plus NDJSON leakage coverage (commits 52dcaebf74, be1bcb4b8c).
  • Import placementpackages/cli/src/session/message-v2.ts: moved the import into the top import block (commit 52dcaebf74).

Validation: 48 focused tests passed. CLI and repository-wide typechecks passed (6 tasks). The actual SDK build ran successfully, and repeated codegen left both canonical and legacy type outputs byte-identical. Prettier and diff checks passed. Pushed normally without verification bypass flags.

Review claims verified false (no change needed)

  • “result.response?.headers never exists in v2; requestID dead” — verified false. The pinned @ai-sdk/provider 2.0.1 LanguageModelV2.doStream declaration explicitly returns response?: { headers?: SharedV2Headers }. The actual pinned Google and Vertex adapter tests observe the supplied request-ID headers; after the privacy fix those observed values deliberately report redacted, while absent headers report unavailable. responseHeaders is not this middleware’s return contract.

Not addressed here

None of the verified code findings are deferred. Raw request IDs and diagnostic free text are intentionally unavailable as values under the privacy policy; availability flags remain. Verdict data-layer persistence is unavailable in this session: matched 0, written 0, verified 0, failed 0, unrecorded 5.

Comment thread packages/cli/src/cli/cmd/generate.ts Outdated
paths: {},
components: {
schemas: {
StepFinishPart: z.toJSONSchema(MessageV2.StepFinishPart, { target: "openapi-3.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.

🟡 Zod meta `ref` leaks non-standard keyword into OpenAPI.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #114, packages/cli/src/cli/cmd/generate.ts:18):

Problem: Zod meta `ref` leaks non-standard keyword into OpenAPI
Detail: ProviderTermination.Info carries `.meta({ ref: "ProviderTermination" })` (termination.ts:20), but zod 4.1.8 (pinned in bun.lock) only honors the `id` meta key for $defs/$ref extraction; every other meta key is Object.assign'ed verbatim onto the emitted schema (to-json-schema.ts:637-638, 764-769). So the published OpenAPI components gain a non-standard `ref` keyword and StepFinishPart gets the termination schema fully inlined rather than referencing the named component — the apparent componentization intent never materializes. Secondarily, the document declares `openapi: "3.1.1"` while both schemas are generated with `target: "openapi-3.0"`, which would emit 3.0-dialect constructs (e.g. `nullable`) invalid in a 3.1.1 document if a nullable field is ever added. Harmless to today's codegen (the generated v2 SDK types are correct), but it pollutes the published contract that SDK codegen consumes.
Suggested fix: Register the schema for componentization with `.meta({ id: "ProviderTermination" })` (zod's id meta drives $defs/$ref extraction — the generate test's `toEqual` between the inlined property and the component would then need adjusting to a $ref expectation), or drop the meta key entirely to stop leaking `ref` into the spec. Also align the toJSONSchema target with the declared 3.1.1 document (default draft-2020-12) or declare the spec 3.0.3.

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

ProviderTermination.Info carries .meta({ ref: "ProviderTermination" }) (termination.ts:20), but zod 4.1.8 (pinned in bun.lock) only honors the id meta key for $defs/$ref extraction; every other meta key is Object.assign'ed verbatim onto the emitted schema (to-json-schema.ts:637-638, 764-769). So the published OpenAPI components gain a non-standard ref keyword and StepFinishPart gets the termination schema fully inlined rather than referencing the named component — the apparent componentization intent never materializes. Secondarily, the document declares openapi: "3.1.1" while both schemas are generated with target: "openapi-3.0", which would emit 3.0-dialect constructs (e.g. nullable) invalid in a 3.1.1 document if a nullable field is ever added. Harmless to today's codegen (the generated v2 SDK types are correct), but it pollutes the published contract that SDK codegen consumes.

        version: "1.0.0",
      },
      paths: {},
      components: {
        schemas: {
          StepFinishPart: z.toJSONSchema(MessageV2.StepFinishPart, { target: "openapi-3.0" }),
          ProviderTermination: z.toJSONSchema(ProviderTermination.Info, { target: "openapi-3.0" }),
        },
      },

const headers = result.response?.headers
// A provider/proxy can echo arbitrary credentials into an ID header.
// Format allowlists cannot distinguish an opaque ID from an opaque key.
const requestID = field(headers?.["x-request-id"] ?? headers?.["x-goog-request-id"], () => false)

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 x-request-id masks nonempty x-goog-request-id.

Suggested change
const requestID = field(headers?.["x-request-id"] ?? headers?.["x-goog-request-id"], () => false)
Select the first nonempty value instead of the first non-nullish one: `const id = [headers?.["x-request-id"], headers?.["x-goog-request-id"]].find((v) => typeof v === "string" && v.length > 0); const requestID = field(id, () => false)`
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #114, packages/cli/src/provider/termination.ts:67):

Problem: Empty x-request-id masks nonempty x-goog-request-id
Detail: `headers?.["x-request-id"] ?? headers?.["x-goog-request-id"]` falls through only on null/undefined, so an empty-string `x-request-id` (which field() maps to unavailable) masks a populated `x-goog-request-id`. The stored termination then reports requestID `unavailable` even though a nonempty ID header exists, contradicting EVENTS.md's contract that all nonempty values from either header are reported (as redacted). Edge case — requires a proxy sending an empty x-request-id alongside a nonempty x-goog-request-id.
Suggested fix: Select the first nonempty value instead of the first non-nullish one: `const id = [headers?.["x-request-id"], headers?.["x-goog-request-id"]].find((v) => typeof v === "string" && v.length > 0); const requestID = field(id, () => false)`

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

headers?.["x-request-id"] ?? headers?.["x-goog-request-id"] falls through only on null/undefined, so an empty-string x-request-id (which field() maps to unavailable) masks a populated x-goog-request-id. The stored termination then reports requestID unavailable even though a nonempty ID header exists, contradicting EVENTS.md's contract that all nonempty values from either header are reported (as redacted). Edge case — requires a proxy sending an empty x-request-id alongside a nonempty x-goog-request-id.

        const result = await doStream()
        let rawReason = field(undefined, () => false)
        let diagnostic = field(undefined, () => false)
        const headers = result.response?.headers
        // A provider/proxy can echo arbitrary credentials into an ID header.
        // Format allowlists cannot distinguish an opaque ID from an opaque key.
        const requestID = field(headers?.["x-request-id"] ?? headers?.["x-goog-request-id"], () => false)

export namespace ProviderTermination {
const Field = z.object({
status: z.enum(["available", "unavailable", "redacted"]),
value: z.string().max(128).optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Field value cap 128 diverges from diagnostic 2048 limit.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #114, packages/cli/src/provider/termination.ts:7):

Problem: Field value cap 128 diverges from diagnostic 2048 limit
Detail: The Field schema hardcodes `value: z.string().max(128)` while `field()` applies a per-call limit (2048 for finishMessage diagnostics, documented in EVENTS.md). The limit is encoded twice with divergent values: today the diagnostic allowlist is `() => false` so no value is ever emitted and nothing breaks, but if a diagnostic value is ever marked available in the 129–2048 range, `field()` would emit it while `Info.safeParse` in `from()` rejects the whole termination object (silently dropping it). Share named constants and keep the schema cap in sync with the runtime limit.
Suggested fix: Define `const VALUE_LIMIT = 128` and `const DIAGNOSTIC_LIMIT = 2048`, parameterize the Field schema (e.g. a factory `Field(limit)`) or widen the stored cap to the largest limit used, so `from()`'s safeParse can never reject a value `field()` emitted.

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 Field schema hardcodes value: z.string().max(128) while field() applies a per-call limit (2048 for finishMessage diagnostics, documented in EVENTS.md). The limit is encoded twice with divergent values: today the diagnostic allowlist is () => false so no value is ever emitted and nothing breaks, but if a diagnostic value is ever marked available in the 129–2048 range, field() would emit it while Info.safeParse in from() rejects the whole termination object (silently dropping it). Share named constants and keep the schema cap in sync with the runtime limit.

export namespace ProviderTermination {
  const Field = z.object({
    status: z.enum(["available", "unavailable", "redacted"]),
    value: z.string().max(128).optional(),
    truncated: z.boolean(),
  })

@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 · 🟡 1 · ⚪ 2 · 0/3 resolved

  • 🟡 packages/cli/src/cli/cmd/generate.ts:18 — Zod meta `ref` leaks non-standard keyword into OpenAPI
  • packages/cli/src/provider/termination.ts:7 — Field value cap 128 diverges from diagnostic 2048 limit
  • packages/cli/src/provider/termination.ts:67 — Empty x-request-id masks nonempty x-goog-request-id
🤖 Fix all 3 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #114 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/cli/cmd/generate.ts:18 — Zod meta `ref` leaks non-standard keyword into OpenAPI
   Detail: ProviderTermination.Info carries `.meta({ ref: "ProviderTermination" })` (termination.ts:20), but zod 4.1.8 (pinned in bun.lock) only honors the `id` meta key for $defs/$ref extraction; every other meta key is Object.assign'ed verbatim onto the emitted schema (to-json-schema.ts:637-638, 764-769). So the published OpenAPI components gain a non-standard `ref` keyword and StepFinishPart gets the termination schema fully inlined rather than referencing the named component — the apparent componentization intent never materializes. Secondarily, the document declares `openapi: "3.1.1"` while both schemas are generated with `target: "openapi-3.0"`, which would emit 3.0-dialect constructs (e.g. `nullable`) invalid in a 3.1.1 document if a nullable field is ever added. Harmless to today's codegen (the generated v2 SDK types are correct), but it pollutes the published contract that SDK codegen consumes.
   Suggested fix: Register the schema for componentization with `.meta({ id: "ProviderTermination" })` (zod's id meta drives $defs/$ref extraction — the generate test's `toEqual` between the inlined property and the component would then need adjusting to a $ref expectation), or drop the meta key entirely to stop leaking `ref` into the spec. Also align the toJSONSchema target with the declared 3.1.1 document (default draft-2020-12) or declare the spec 3.0.3.
2. packages/cli/src/provider/termination.ts:7 — Field value cap 128 diverges from diagnostic 2048 limit
   Detail: The Field schema hardcodes `value: z.string().max(128)` while `field()` applies a per-call limit (2048 for finishMessage diagnostics, documented in EVENTS.md). The limit is encoded twice with divergent values: today the diagnostic allowlist is `() => false` so no value is ever emitted and nothing breaks, but if a diagnostic value is ever marked available in the 129–2048 range, `field()` would emit it while `Info.safeParse` in `from()` rejects the whole termination object (silently dropping it). Share named constants and keep the schema cap in sync with the runtime limit.
   Suggested fix: Define `const VALUE_LIMIT = 128` and `const DIAGNOSTIC_LIMIT = 2048`, parameterize the Field schema (e.g. a factory `Field(limit)`) or widen the stored cap to the largest limit used, so `from()`'s safeParse can never reject a value `field()` emitted.
3. packages/cli/src/provider/termination.ts:67 — Empty x-request-id masks nonempty x-goog-request-id
   Detail: `headers?.["x-request-id"] ?? headers?.["x-goog-request-id"]` falls through only on null/undefined, so an empty-string `x-request-id` (which field() maps to unavailable) masks a populated `x-goog-request-id`. The stored termination then reports requestID `unavailable` even though a nonempty ID header exists, contradicting EVENTS.md's contract that all nonempty values from either header are reported (as redacted). Edge case — requires a proxy sending an empty x-request-id alongside a nonempty x-goog-request-id.
   Suggested fix: Select the first nonempty value instead of the first non-nullish one: `const id = [headers?.["x-request-id"], headers?.["x-goog-request-id"]].find((v) => typeof v === "string" && v.length > 0); const requestID = field(id, () => false)`
📋 Out-of-diff findings (3)
Sev Location Finding
🟡 packages/cli/src/cli/cmd/generate.ts:18 Zod meta `ref` leaks non-standard keyword into OpenAPI
packages/cli/src/provider/termination.ts:7 Field value cap 128 diverges from diagnostic 2048 limit
packages/cli/src/provider/termination.ts:67 Empty x-request-id masks nonempty x-goog-request-id

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


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

@byapparov

Copy link
Copy Markdown
Contributor Author

Review response — PR #114

Verified and fixed the three new findings from the review of be1bcb4b8c; earlier review verdicts remain in the preceding response.

Issues addressed (pushed to this PR)

  • Zod metadata and OpenAPI dialectpackages/cli/src/cli/cmd/generate.ts: use an isolated registry for named components and the JSON Schema 2020-12 target appropriate to OpenAPI 3.1.1. Export component $refs without legacy ref/id metadata or fragment resource $ids, then regenerate the SDK (commit c1d173cacc).
  • Empty primary request-ID header masks fallbackpackages/cli/src/provider/termination.ts: select the first nonempty ID header. Actual adapter regressions verify a nonempty Google fallback reports redacted, including its oversize flag, while no raw value is retained (commit c1d173cacc).
  • Diagnostic schema/runtime limit mismatchpackages/cli/src/provider/termination.ts: parameterize field schemas with the same named limits used at runtime: 128 characters for raw reasons/request IDs and 2048 for diagnostics. This was a latent consistency issue; default diagnostic text remains entirely suppressed. Boundary tests cover 2048/2049 and retain the 128-character raw-reason bound (commit c1d173cacc).

Validation: 51 focused tests passed. CLI, SDK, and repository-wide typechecks passed (6 tasks). The actual SDK codegen succeeded; a second generation produced byte-identical canonical and legacy type outputs. Prettier and diff checks passed. Pushed normally without verification bypass flags.

Review claims verified false (no change needed)

None in this review round.

Not addressed here

None in this review round. Data-layer verdict persistence is unavailable: matched 0, written 0, verified 0, failed 0, unrecorded 3.

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