fix(langgraph): respect meta defaults in LastValue (#1825)

This commit is contained in:
Hunter Lovell
2025-12-17 07:09:26 -08:00
committed by GitHub
parent de1454aef3
commit 2340a541e2
5 changed files with 214 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@langchain/langgraph": patch
---
respect meta defaults in `LastValue`
@@ -15,8 +15,15 @@ export class LastValue<Value> extends BaseChannel<Value, Value, Value> {
// value is an array so we don't misinterpret an update to undefined as no write
value: [Value] | [] = [];
constructor(protected initialValueFactory?: () => Value) {
super();
if (initialValueFactory) {
this.value = [initialValueFactory()];
}
}
fromCheckpoint(checkpoint?: Value) {
const empty = new LastValue<Value>();
const empty = new LastValue<Value>(this.initialValueFactory);
if (typeof checkpoint !== "undefined") {
empty.value = [checkpoint];
}
+1 -1
View File
@@ -163,7 +163,7 @@ export class SchemaMetaRegistry {
InferInteropZodOutput<typeof channelSchema>
>(meta.reducer.fn, meta.default);
} else {
channels[key] = new LastValue();
channels[key] = new LastValue(meta?.default);
}
}
return channels as InteropZodToStateDefinition<T>;
@@ -47,6 +47,93 @@ describe("LastValue", () => {
const channel2 = restoredChannel.fromCheckpoint(checkpoint);
expect(channel2.get()).toBe(value);
});
describe("with initialValueFactory", () => {
it("should initialize with default value from factory", () => {
const channel = new LastValue<string>(() => "default-value");
expect(channel.get()).toBe("default-value");
expect(channel.isAvailable()).toBe(true);
});
it("should allow updates to override initial value", () => {
const channel = new LastValue<string>(() => "default-value");
expect(channel.get()).toBe("default-value");
channel.update(["updated-value"]);
expect(channel.get()).toBe("updated-value");
});
it("should preserve initialValueFactory in checkpoint restoration", () => {
const initialValueFactory = () => "default-value";
const channel = new LastValue<string>(initialValueFactory);
expect(channel.get()).toBe("default-value");
channel.update(["updated-value"]);
const checkpoint = channel.checkpoint();
const restoredChannel = new LastValue<string>(initialValueFactory);
const channel2 = restoredChannel.fromCheckpoint(checkpoint);
// Checkpoint value takes precedence
expect(channel2.get()).toBe("updated-value");
// Create a new channel from checkpoint without providing factory
// This tests that the factory is preserved in the restored channel
const channel3 = new LastValue<string>(initialValueFactory);
const channel4 = channel3.fromCheckpoint(checkpoint);
expect(channel4.get()).toBe("updated-value");
});
it("should use initial value when checkpoint is undefined", () => {
const initialValueFactory = () => "default-value";
const channel = new LastValue<string>(initialValueFactory);
const restoredChannel = channel.fromCheckpoint(undefined);
// When checkpoint is undefined, should use initial value
expect(restoredChannel.get()).toBe("default-value");
});
it("should work with function that returns different values", () => {
let callCount = 0;
const initialValueFactory = () => {
callCount += 1;
return `value-${callCount}`;
};
const channel1 = new LastValue<string>(initialValueFactory);
expect(channel1.get()).toBe("value-1");
const channel2 = new LastValue<string>(initialValueFactory);
expect(channel2.get()).toBe("value-2");
});
it("should handle initialValueFactory returning falsy values", () => {
const channelZero = new LastValue<number>(() => 0);
expect(channelZero.get()).toBe(0);
const channelEmpty = new LastValue<string>(() => "");
expect(channelEmpty.get()).toBe("");
const channelFalse = new LastValue<boolean>(() => false);
expect(channelFalse.get()).toBe(false);
});
it("should handle complex objects from initialValueFactory", () => {
const initialValueFactory = () => ({ foo: "bar", count: 42 });
const channel = new LastValue<{ foo: string; count: number }>(
initialValueFactory
);
const value = channel.get();
expect(value.foo).toBe("bar");
expect(value.count).toBe(42);
});
it("should work without initialValueFactory", () => {
const channel = new LastValue<number>();
expect(() => channel.get()).toThrow(EmptyChannelError);
channel.update([42]);
expect(channel.get()).toBe(42);
});
});
});
describe("Topic", () => {
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import * as z4 from "zod/v4";
import { StateGraph } from "../graph/state.js";
import { END, START } from "../constants.js";
import { _AnyIdAIMessage, _AnyIdHumanMessage } from "./utils.js";
@@ -10,6 +11,7 @@ import {
getStateTypeSchema,
} from "../graph/zod/schema.js";
import { MessagesZodState } from "../graph/messages_annotation.js";
import { registry } from "../graph/zod/zod-registry.js";
describe("StateGraph with Zod schemas", () => {
it("should accept Zod schema as input in addNode", async () => {
@@ -144,4 +146,115 @@ describe("StateGraph with Zod schemas", () => {
},
});
});
describe("registry default values", () => {
it("should apply registry default when field is missing from input", async () => {
const stateSchema = z4.object({
foo: z4.string().default("zod-default"),
bar: z4.string().register(registry, {
default: () => "registry-default",
}),
baz: z4.string(),
});
const graph = new StateGraph(stateSchema)
.addNode("process", (state) => {
// Verify defaults are applied when node receives state
expect(state.foo).toBe("zod-default");
expect(state.bar).toBe("registry-default");
expect(state.baz).toBe("provided");
return {};
})
.addEdge(START, "process")
.addEdge("process", END)
.compile();
// Only provide baz, foo and bar should get defaults
const result = await graph.invoke({ baz: "provided" });
// Verify defaults are in final output
expect(result.foo).toBe("zod-default");
expect(result.bar).toBe("registry-default");
expect(result.baz).toBe("provided");
});
it("should prioritize provided input over registry default", async () => {
const stateSchema = z4.object({
bar: z4.string().register(registry, {
default: () => "registry-default",
}),
});
const graph = new StateGraph(stateSchema)
.addNode("process", (state) => {
// Verify provided value takes precedence over default
expect(state.bar).toBe("provided-value");
return {};
})
.addEdge(START, "process")
.addEdge("process", END)
.compile();
const result = await graph.invoke({ bar: "provided-value" });
expect(result.bar).toBe("provided-value");
});
it("should work with registry default alongside reducer", async () => {
const stateSchema = z4.object({
items: z4.array(z4.string()).register(registry, {
default: () => ["initial"],
reducer: {
fn: (a, b) => a.concat(Array.isArray(b) ? b : [b]),
},
}),
});
const graph = new StateGraph(stateSchema)
.addNode("add", (state) => {
// Verify default is applied before reducer processes update
expect(state.items).toEqual(["initial"]);
return { items: ["new"] };
})
.addEdge(START, "add")
.addEdge("add", END)
.compile();
const result = await graph.invoke({});
// Verify reducer combined default with update
expect(result.items).toEqual(["initial", "new"]);
});
it("should work when all combinations of defaults are present", async () => {
const stateSchema = z4.object({
withZod: z4.string().default("zod-default"),
withRegistry: z4.string().register(registry, {
default: () => "registry-default",
}),
withBoth: z4
.string()
.default("zod-default")
.register(registry, {
default: () => "registry-default",
}),
withNeither: z4.string(),
});
const graph = new StateGraph(stateSchema)
.addNode("process", (state) => {
// Verify defaults are applied correctly
expect(state.withZod).toBe("zod-default");
expect(state.withRegistry).toBe("registry-default");
// Zod default takes precedence during parsing, so registry default isn't used
expect(state.withBoth).toBe("zod-default");
return {};
})
.addEdge(START, "process")
.addEdge("process", END)
.compile();
const result = await graph.invoke({});
expect(result.withZod).toBe("zod-default");
expect(result.withRegistry).toBe("registry-default");
expect(result.withBoth).toBe("zod-default"); // Zod default takes precedence during parsing
});
});
});