fix(langgraph): validate ID-mapped resumes before committing them

A resume passed as Command(resume={interrupt_id: value}) is already in
the scratchpad when interrupt() runs, so it took the replay branch, which
queued the RESUME write before validation. A rejected value was therefore
persisted and every later correction re-validated it.
This commit is contained in:
Elior Nataf Lackritz
2026-09-10 13:11:54 -04:00
parent 3913144bdd
commit c0e1399e22
2 changed files with 11 additions and 5 deletions
+3 -2
View File
@@ -982,9 +982,10 @@ def interrupt(
# find previous resume values
if scratchpad.resume:
if idx < len(scratchpad.resume):
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
v = scratchpad.resume[idx]
return adapter.validate_python(v) if adapter else v
validated = adapter.validate_python(v) if adapter else v
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
return validated
# find current resume value
v = scratchpad.get_null_resume(True)
if v is not None:
+8 -3
View File
@@ -163,8 +163,9 @@ def test_interrupt_response_schema(
}
@pytest.mark.parametrize("resume_style", ["null", "map"])
def test_interrupt_response_schema_rejects_invalid_resume(
sync_checkpointer: BaseCheckpointSaver,
sync_checkpointer: BaseCheckpointSaver, resume_style: str
) -> None:
class State(TypedDict):
answer: Any
@@ -180,10 +181,14 @@ def test_interrupt_response_schema_rejects_invalid_resume(
)
config = {"configurable": {"thread_id": "1"}}
graph.invoke({"answer": None}, config)
[pending] = graph.get_state(config).tasks[0].interrupts
def resume(value: dict[str, Any]) -> Command:
return Command(resume=value if resume_style == "null" else {pending.id: value})
with pytest.raises(ValidationError, match="approved"):
graph.invoke(Command(resume={"approved": "nope"}), config)
graph.invoke(resume({"approved": "nope"}), config)
assert graph.invoke(Command(resume={"approved": False}), config) == {
assert graph.invoke(resume({"approved": False}), config) == {
"answer": Decision(approved=False)
}