From c0e1399e22574dcd555fc66c611c47f4335e16c7 Mon Sep 17 00:00:00 2001 From: Elior Nataf Lackritz Date: Thu, 10 Sep 2026 13:11:54 -0400 Subject: [PATCH] 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. --- libs/langgraph/langgraph/types.py | 5 +++-- libs/langgraph/tests/test_interruption.py | 11 ++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/libs/langgraph/langgraph/types.py b/libs/langgraph/langgraph/types.py index 6dc0f19fe..02e745fb7 100644 --- a/libs/langgraph/langgraph/types.py +++ b/libs/langgraph/langgraph/types.py @@ -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: diff --git a/libs/langgraph/tests/test_interruption.py b/libs/langgraph/tests/test_interruption.py index 3d5569149..2e5e0a280 100644 --- a/libs/langgraph/tests/test_interruption.py +++ b/libs/langgraph/tests/test_interruption.py @@ -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) }