Skip to content

Commit 0bc5f90

Browse files
authored
fix(sdk-js): preserve Zod v4 state fields in LangGraph output_schema (CopilotKit#4358)
- Adds a `zodState` helper that attaches a `~standard.jsonSchema.input` hook to Standard-Schema–compatible schemas. Without it, Zod v4 fields carry only `~standard.validate` + `vendor`, so LangGraph's `isStandardJSONSchema()` returns false and `getJsonSchemaFromSchema` (called from `StateSchema.getJsonSchema`) silently drops them from the graph's `output_schema`. The fields then get filtered out of AG-UI `STATE_SNAPSHOT` payloads even though the underlying thread state has them, and never reach `useAgent().state.*` on the frontend. - Wraps the internal `copilotkit` field in `copilotKitStateSchema` with `zodState(...)`, and exports the helper for user state schemas to do the same. - Bumps `@ag-ui/langgraph` to `0.0.30` and `@langchain/{core,langgraph}` / `langchain` to the `1.1.41` / `1.2.9` / `1.3.4` line — this is the dependency upgrade that exposes the Zod v4 behavior the helper works around. This recreates the sdk-js portion of CopilotKit#4320 in isolation so it can land without the integration-demo refresh.
2 parents 26039c5 + e504254 commit 0bc5f90

5 files changed

Lines changed: 483 additions & 605 deletions

File tree

packages/runtime/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@
9292
"@ag-ui/client": "0.0.52",
9393
"@ag-ui/core": "0.0.52",
9494
"@ag-ui/encoder": "0.0.52",
95-
"@ag-ui/langgraph": "0.0.29",
95+
"@ag-ui/langgraph": "0.0.31",
9696
"@ag-ui/mcp-apps-middleware": "0.0.3",
9797
"@ai-sdk/anthropic": "^3.0.49",
9898
"@ai-sdk/google": "^3.0.33",

packages/sdk-js/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,18 @@
5959
"attw": "attw --pack . --profile node16"
6060
},
6161
"dependencies": {
62-
"@ag-ui/langgraph": "0.0.29",
62+
"@ag-ui/langgraph": "0.0.31",
6363
"@copilotkit/shared": "workspace:*"
6464
},
6565
"devDependencies": {
66-
"@langchain/core": "^1.1.8",
67-
"@langchain/langgraph": "^1.0.7",
66+
"@langchain/core": "^1.1.41",
67+
"@langchain/langgraph": "^1.2.9",
6868
"@swc/core": "1.5.28",
6969
"@types/express": "^4.17.21",
7070
"@types/node": "^18.11.17",
7171
"@whatwg-node/server": "^0.9.34",
7272
"eslint": "^8.56.0",
73-
"langchain": "^1.2.3",
73+
"langchain": "^1.3.4",
7474
"nodemon": "^3.1.3",
7575
"ts-node": "^10.9.2",
7676
"tsconfig": "workspace:*",

packages/sdk-js/src/langgraph/__tests__/middleware.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919

2020
import { describe, it, expect } from "vitest";
21+
import * as z from "zod";
2122
import {
2223
AIMessage,
2324
HumanMessage,
@@ -27,6 +28,7 @@ import {
2728
import {
2829
copilotkitMiddleware,
2930
createCopilotkitMiddleware,
31+
zodState,
3032
} from "../middleware";
3133

3234
// ---------------------------------------------------------------------------
@@ -495,3 +497,115 @@ describe("afterAgent", () => {
495497
expect(result!.copilotkit.originalAIMessageId).toBeUndefined();
496498
});
497499
});
500+
501+
// ---------------------------------------------------------------------------
502+
// zodState — Standard-Schema JSON-schema augmentation
503+
// ---------------------------------------------------------------------------
504+
//
505+
// Contract: a Zod v4 schema only carries `~standard.validate` + `vendor`, so
506+
// LangGraph's `isStandardJSONSchema()` returns false and the field is dropped
507+
// from `output_schema`. `zodState` patches `~standard.jsonSchema.input` onto
508+
// the schema so the field survives serialization and reaches the frontend
509+
// via AG-UI `STATE_SNAPSHOT` events.
510+
511+
type JsonSchema = {
512+
type?: string;
513+
properties?: Record<string, unknown>;
514+
};
515+
type StdJson = { jsonSchema?: { input: () => JsonSchema } };
516+
const standardOf = (s: object): StdJson | undefined =>
517+
(s as { "~standard"?: StdJson })["~standard"];
518+
const hasZodV4ToJsonSchema = (): boolean =>
519+
typeof (z as { toJSONSchema?: unknown }).toJSONSchema === "function";
520+
521+
describe("zodState", () => {
522+
it("attaches a ~standard.jsonSchema.input hook to a Zod schema", () => {
523+
const schema = z.object({ name: z.string() });
524+
expect(standardOf(schema)?.jsonSchema).toBeUndefined();
525+
526+
const wrapped = zodState(schema);
527+
528+
const std = standardOf(wrapped);
529+
expect(std?.jsonSchema).toBeDefined();
530+
expect(typeof std?.jsonSchema?.input).toBe("function");
531+
});
532+
533+
it("returns a plain object from input() (real JSON Schema when zod v4 is available, else `{}`)", () => {
534+
// The contract langgraph cares about is just "input() returns an
535+
// object" — `isStandardJSONSchema()` doesn't introspect the shape.
536+
// When zod v4's `toJSONSchema` is present we get the real schema;
537+
// otherwise we get `{}`, which is still enough for the field to
538+
// appear in `output_schema` (langgraph-api treats it as opaque).
539+
const schema = z.object({ todos: z.array(z.string()) });
540+
const wrapped = zodState(schema);
541+
542+
const json = standardOf(wrapped)?.jsonSchema?.input();
543+
544+
expect(json).toBeTypeOf("object");
545+
expect(json).not.toBeNull();
546+
if (hasZodV4ToJsonSchema()) {
547+
expect(json?.type).toBe("object");
548+
expect(json?.properties?.todos).toBeDefined();
549+
}
550+
});
551+
552+
it("caches the JSON Schema across calls (input() returns the same object)", () => {
553+
const schema = z.object({ x: z.string() });
554+
const wrapped = zodState(schema);
555+
const std = standardOf(wrapped);
556+
557+
const a = std?.jsonSchema?.input();
558+
const b = std?.jsonSchema?.input();
559+
560+
expect(a).toBe(b);
561+
});
562+
563+
it("does not overwrite an existing jsonSchema hook", () => {
564+
const schema = z.object({ x: z.string() });
565+
const preset: StdJson["jsonSchema"] = { input: () => ({}) };
566+
const std = standardOf(schema);
567+
expect(std).toBeDefined();
568+
std!.jsonSchema = preset;
569+
570+
zodState(schema);
571+
572+
expect(standardOf(schema)?.jsonSchema).toBe(preset);
573+
});
574+
575+
it("returns the same reference (mutates rather than copies)", () => {
576+
const schema = z.object({ x: z.string() });
577+
expect(zodState(schema)).toBe(schema);
578+
});
579+
580+
it("is a no-op for a value without ~standard metadata", () => {
581+
const plain = { foo: "bar" };
582+
expect(() => zodState(plain)).not.toThrow();
583+
expect(zodState(plain)).toBe(plain);
584+
});
585+
586+
it("works on optional / array / default-wrapped schemas (state-field shapes)", () => {
587+
const todos = zodState(
588+
z.array(z.object({ id: z.string(), text: z.string() })).default(() => []),
589+
);
590+
591+
const std = standardOf(todos);
592+
expect(std).toBeDefined();
593+
expect(std?.jsonSchema).toBeDefined();
594+
const json = std?.jsonSchema?.input();
595+
expect(json).toBeTypeOf("object");
596+
if (hasZodV4ToJsonSchema()) {
597+
expect(json?.type).toBe("array");
598+
}
599+
});
600+
601+
it("makes the wrapped field pass a StandardJSONSchemaV1-style probe", () => {
602+
// Mirrors what LangGraph's isStandardJSONSchema() looks for: an `input`
603+
// function on `~standard.jsonSchema` that returns a plain object.
604+
const schema = zodState(z.object({ liked: z.array(z.string()) }));
605+
const std = standardOf(schema);
606+
607+
expect(std?.jsonSchema).toBeDefined();
608+
expect(typeof std?.jsonSchema?.input).toBe("function");
609+
expect(typeof std?.jsonSchema?.input()).toBe("object");
610+
});
611+
});

packages/sdk-js/src/langgraph/middleware.ts

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,77 @@
11
import { createMiddleware, AIMessage, SystemMessage } from "langchain";
22
import type { InteropZodObject } from "@langchain/core/utils/types";
3+
import type {
4+
StandardJSONSchemaV1,
5+
StandardSchemaV1,
6+
} from "@standard-schema/spec";
37
import * as z from "zod";
48

9+
type WithJsonSchema<T> = T extends { "~standard": infer S }
10+
? Omit<T, "~standard"> & {
11+
"~standard": S &
12+
StandardJSONSchemaV1.Props<
13+
S extends StandardSchemaV1.Props<infer I, any> ? I : unknown,
14+
S extends StandardSchemaV1.Props<any, infer O> ? O : unknown
15+
>;
16+
}
17+
: T;
18+
19+
/**
20+
* Augment a Standard-Schema–compatible schema (e.g. Zod) with a
21+
* `~standard.jsonSchema.input` hook so LangGraph's
22+
* `getJsonSchemaFromSchema` (called from `StateSchema.getJsonSchema`)
23+
* can serialize the field.
24+
*
25+
* Without this, Zod v4 fields carry `~standard.validate` + `vendor` only,
26+
* and `isStandardJSONSchema()` returns false, so the field is silently
27+
* dropped from the graph's `output_schema`. That makes AG-UI
28+
* `STATE_SNAPSHOT` events filter the field out of the payload sent to
29+
* the frontend even though the underlying thread state has the value.
30+
*
31+
* Use this on any custom state field you want visible to the frontend
32+
* via `useAgent().state.*`.
33+
*
34+
* @example
35+
* ```ts
36+
* import { zodState } from "@copilotkit/sdk-js/langgraph";
37+
*
38+
* const stateSchema = z.object({
39+
* todos: zodState(z.array(TodoSchema).default(() => [])),
40+
* });
41+
* ```
42+
*/
43+
export function zodState<T extends object>(schema: T): WithJsonSchema<T> {
44+
const std = (schema as { "~standard"?: { jsonSchema?: unknown } })[
45+
"~standard"
46+
];
47+
if (std && typeof std === "object" && !("jsonSchema" in std)) {
48+
let cached: Record<string, unknown> | undefined;
49+
std.jsonSchema = {
50+
input: () => {
51+
if (cached) return cached;
52+
// Prefer zod-v4's native `toJSONSchema` when available. Falls back to
53+
// an empty object, which is sufficient for the field to appear in the
54+
// graph's output_schema (langgraph-api treats it as an opaque field).
55+
try {
56+
const maybeV4ToJsonSchema = (
57+
z as unknown as {
58+
toJSONSchema?: (s: unknown) => Record<string, unknown>;
59+
}
60+
).toJSONSchema;
61+
cached =
62+
typeof maybeV4ToJsonSchema === "function"
63+
? maybeV4ToJsonSchema(schema)
64+
: {};
65+
} catch {
66+
cached = {};
67+
}
68+
return cached;
69+
},
70+
};
71+
}
72+
return schema as WithJsonSchema<T>;
73+
}
74+
575
/**
676
* Internal/framework state keys that should never be auto-surfaced to the
777
* LLM as user-facing state. These are reducer-managed message buckets,
@@ -215,14 +285,16 @@ const createAppContextBeforeAgent = (state, runtime) => {
215285
* ```
216286
*/
217287
const copilotKitStateSchema = z.object({
218-
copilotkit: z
219-
.object({
220-
actions: z.array(z.any()),
221-
context: z.any().optional(),
222-
interceptedToolCalls: z.array(z.any()).optional(),
223-
originalAIMessageId: z.string().optional(),
224-
})
225-
.optional(),
288+
copilotkit: zodState(
289+
z
290+
.object({
291+
actions: z.array(z.any()),
292+
context: z.any().optional(),
293+
interceptedToolCalls: z.array(z.any()).optional(),
294+
originalAIMessageId: z.string().optional(),
295+
})
296+
.optional(),
297+
),
226298
});
227299

228300
const buildMiddlewareInput = (exposeState: ExposeStateOption) => ({

0 commit comments

Comments
 (0)