Skip to content

fix(mastra): support Zod 4 by dropping @ai-sdk/ui-utils - #3007

Closed
sander-su wants to merge 2 commits into
ag-ui-protocol:mainfrom
sander-su:fix/mastra-zod4
Closed

sander-su wants to merge 2 commits into
ag-ui-protocol:mainfrom
sander-su:fix/mastra-zod4

Conversation

@sander-su

Copy link
Copy Markdown
Contributor

Closes #2874 (follow-up to the discussion there).

Summary

@ag-ui/mastra pulled in a nested zod@3 copy only through @ai-sdk/ui-utils, which is an AI SDK v4-era package that peers zod ^3.23.8 (every published version, including the latest 1.2.11). The adapter used exactly one symbol from it: parsePartialJson. Apps on Zod 4 therefore got a duplicate Zod 3 plus the ERESOLVE overriding peer dependency warning described in the issue.

This PR:

  • drops @ai-sdk/ui-utils
  • imports parsePartialJson from the current AI SDK (ai, which peers zod ^3.25.76 || ^4.1.8)
  • widens the adapter's own zod dependency from ^3.25.76 to ^3.25.76 || ^4.0.0 (same 3.x floor, matching @mastra/core / @mastra/client-js)

The sync → async change

The current AI SDK only ships an async parsePartialJson (Promise<{ value, state }>, same shape otherwise). Its single caller, parseStreamingWorkingMemoryUpdate, runs inside the chunk pipeline, so that chain is now async and awaited at its call sites:

parseStreamingWorkingMemoryUpdate → processChunk → releaseDeferredReplay (its .some() short-circuit becomes an awaited for…of with the same early return) → handleChunk → the flush returned by createChunkProcessor.

No public API change. None of these functions are exported. createChunkProcessor is private, the emitted .d.ts contains none of their signatures, and MastraAgent's public methods already returned Observables/Promises. Both drivers already awaited the callback (@mastra/client-js's processDataStream does await onChunk(chunk); the local path is a for await loop), so chunk ordering is unchanged.

Cancellation (#2288): why the abort listener now defers subscriber.complete()

Making the pipeline async surfaced an ordering issue in the #2288 remote-cancellation guarantee. cancellation-remote-producer.test.ts > still cancels the run when the caller's controller aborts failed: the producer had delivered 1 chunk and had not stopped (stoppedEarly === false), yet the run had already settled.

Cause: when the run is aborted mid-stream the producer is suspended in await onChunk(...). The abort listener called subscriber.complete() synchronously, so the run settled before the producer resumed and re-checked its aborted signal. With the previously sync pipeline the producer happened to win that race.

Fix: the abort listener now settles the Observable on the next macrotask (setTimeout(…, 0)). Pending microtasks, the producer's resumption included, run first, so the producer observes the abort and stops before the run settles. The reasoning is also documented inline in the listener.

Trade-offs I'd like a maintainer's view on:

  • It orders by the event loop, not by an explicit handshake with the producer. In production the per-run handle also aborts the underlying fetch, which tears the stream down regardless; the deferral restores the "producer stopped before the run settles" ordering the [Bug]: MastraAgent Observable run() never propagates cancellation — stopping a run from the client does not stop generation #2288 tests assert.
  • There is a one-tick window between abort and completion in which a chunk already being processed may still emit an event to the subscriber. The consumption loops stop pulling new chunks as soon as they see the aborted signal. No existing test covers this window.
  • It applies to local and remote runs alike: a cancelled run of either kind now settles one tick later.

If you'd prefer an explicit guard (stop emitting once abortSignal.aborted, plus an explicit producer-stopped handshake) over the timer, I'm happy to rework it that way.

Testing

Run locally in integrations/mastra/typescript:

  • pnpm typecheck: clean
  • pnpm test: 31 files / 522 tests passed, including the full cancellation-remote-producer, cancellation, state-delta and working-memory-sync suites
  • pnpm build: clean
  • lockfile validated with pnpm install --frozen-lockfile --filter @ag-ui/mastra

Not done: the full monorepo test matrix (only @ag-ui/mastra is touched). The lockfile diff includes the ai@5 subtree and pnpm's re-resolution of zod-peering packages; happy to regenerate it with your registry setup if CI prefers.

@sander-su
sander-su requested a review from a team as a code owner October 9, 2026 12:34
…ol#2874)

@ag-ui/mastra pinned a nested zod@^3.25.76 solely through its
@ai-sdk/ui-utils dependency, forcing a second Zod 3 copy into apps on
Zod 4 (the ERESOLVE/duplicate-zod problem in ag-ui-protocol#2874). The only use of
@ai-sdk/ui-utils was a single parsePartialJson import.

Replace it with parsePartialJson from the current AI SDK ('ai'), which
is zod-4-compatible (peers zod ^3.25.76 || ^4.1.8). That function is
async, so the chunk pipeline it feeds (parseStreamingWorkingMemoryUpdate
-> processChunk -> releaseDeferredReplay -> handleChunk -> flush) is made
async and awaited at its call sites.

Awaiting the chunk pipeline leaves a remote producer suspended in
'await onChunk(...)' when a run is cancelled, so the abort listener's
synchronous subscriber.complete() tore the run down before the producer
re-observed the aborted signal and stopped upstream (ag-ui-protocol#2288). Defer that
completion by one macrotask so the producer yields and stops first.

- drop @ai-sdk/ui-utils, add ai
- widen zod to ^3.25.76 || ^4.0.0 (keeps the existing 3.x floor, adds
  Zod 4; matches @mastra/core and @mastra/client-js)

typecheck + 522 tests + build pass locally, including the ag-ui-protocol#2288
remote-cancellation suite.
Resolve conflicts with main's AG-UI 1.0 adapter (@ag-ui/mastra 2.0.1).

- Re-apply the async parsePartialJson chain to main's mastra.ts, including
  the new `return handleChunk(...)` inside processChunk and the external
  flush() call sites.
- Pin ai to ^5.0.207, the first 5.x release that pins
  @ai-sdk/provider-utils 3.0.28, outside GHSA-866g-f22w-33x8 /
  CVE-2026-8769 (ag-ui-protocol#3096). Resolves to ai 5.0.278 / provider-utils 3.0.41.
- Rework the ag-ui-protocol#2288 cancellation ordering for main's new abort listener:
  the cancelled RUN_FINISHED is still emitted synchronously, and only
  subscriber.complete() is deferred one macrotask. Deferring the whole
  block (the previous approach) let the drained stream end the run first
  and lost the cancelled outcome; deferring nothing settled the run before
  the remote producer saw its aborted signal.

typecheck clean, 35 files / 687 tests pass; cancellation, state-snapshot
and working-memory suites pass 10/10 repeated runs.
sander-su added a commit to sander-su/ag-ui that referenced this pull request Oct 27, 2026
…-protocol#3096, ag-ui-protocol#2874)

@ag-ui/mastra used one function from @ai-sdk/ui-utils, parsePartialJson,
to read working memory out of streaming tool-call arguments. ui-utils is
the AI SDK 4 package: its last release (1.2.11) pins
@ai-sdk/provider-utils@2.2.8, affected by GHSA-866g-f22w-33x8 /
CVE-2026-8769 with no patched 2.x, and peers zod ^3.23.8, which forces a
nested Zod 3 copy into apps on Zod 4.

Fix it the way Mastra fixed the same dependency in @mastra/client-js
(mastra-ai/mastra#27297), using the same layout:
src/utils/ai-sdk-v4/{fix-json,parse-partial-json,types,index}.ts, taken
from Mastra's copy at c56d026c5194b1977e5321f429565eedbccf2c40
(Apache-2.0, attribution kept). The code is identical to Mastra's apart
from prettier formatting; types.ts and index.ts keep only what this
package uses (JSONValue, parsePartialJson). parsePartialJson parses with
plain JSON.parse instead of provider-utils' safeParseJSON, so no AI SDK
package is needed.

The function stays synchronous, so parseStreamingWorkingMemoryUpdate and
the chunk pipeline are unchanged; nothing in streaming or cancellation
moves. This replaces the async `ai` approach of ag-ui-protocol#3007.

- remove @ai-sdk/ui-utils from dependencies
- widen zod to ^3.25.76 || ^4.0.0
- add src/utils/ai-sdk-v4/ai-sdk-v4.test.ts (Mastra's parse-state cases
  plus the working-memory envelope shapes)

typecheck clean; 47 files / 891 tests pass; build and test:exports pass.
A clean consumer install with @mastra/core 1.79.0 and @mastra/client-js
1.56.0 resolves no @ai-sdk/ui-utils or provider-utils 2.x.
sander-su added a commit to sander-su/ag-ui that referenced this pull request Oct 27, 2026
…-protocol#3096, ag-ui-protocol#2874)

@ag-ui/mastra used one function from @ai-sdk/ui-utils, parsePartialJson,
to read working memory out of streaming tool-call arguments. ui-utils is
the AI SDK 4 package: its last release (1.2.11) pins
@ai-sdk/provider-utils@2.2.8, affected by GHSA-866g-f22w-33x8 /
CVE-2026-8769 with no patched 2.x, and peers zod ^3.23.8, which forces a
nested Zod 3 copy into apps on Zod 4.

Fix it the way Mastra fixed the same dependency in @mastra/client-js
(mastra-ai/mastra#27297), using the same layout:
src/utils/ai-sdk-v4/{fix-json,parse-partial-json,types,index}.ts, taken
from Mastra's copy at c56d026c5194b1977e5321f429565eedbccf2c40
(Apache-2.0, attribution kept). The code is identical to Mastra's apart
from prettier formatting; types.ts and index.ts keep only what this
package uses (JSONValue, parsePartialJson). parsePartialJson parses with
plain JSON.parse instead of provider-utils' safeParseJSON, so no AI SDK
package is needed.

The function stays synchronous, so parseStreamingWorkingMemoryUpdate and
the chunk pipeline are unchanged; nothing in streaming or cancellation
moves. This replaces the async `ai` approach of ag-ui-protocol#3007.

- remove @ai-sdk/ui-utils from dependencies
- widen zod to ^3.25.76 || ^4.0.0
- add src/utils/ai-sdk-v4/ai-sdk-v4.test.ts (Mastra's parse-state cases
  plus the working-memory envelope shapes)

typecheck clean; 47 files / 891 tests pass; build and test:exports pass.
A clean consumer install with @mastra/core 1.79.0 and @mastra/client-js
1.56.0 resolves no @ai-sdk/ui-utils or provider-utils 2.x.
@sander-su

Copy link
Copy Markdown
Contributor Author

Superseded by #3224.

Following the preference for a synchronous parser on #3096, and the way Mastra fixed the same dependency in mastra-ai/mastra#27297, #3224 vendors Mastra's sync parsePartialJson instead of importing it from ai@5. That removes the async chunk pipeline and the cancellation setTimeout this PR needed (#2288), and avoids the undici@5 advisories that every ai@5 release brings in through @ai-sdk/provider-utils@3.0.x.

Closing this one so the review can continue on #3224.

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.

[Feature]: Support Zod 4 in @ag-ui/mastra

1 participant