from __future__ import annotations import sys from typing import Any import pytest from langchain_core.callbacks.base import BaseCallbackHandler from langchain_core.callbacks.manager import CallbackManager from langgraph.checkpoint.memory import InMemorySaver from typing_extensions import TypedDict from langgraph.callbacks import ( GraphCallbackHandler, GraphInterruptEvent, GraphResumeEvent, ) from langgraph.graph import START, StateGraph from langgraph.types import Command, Interrupt, interrupt NEEDS_CONTEXTVARS = pytest.mark.skipif( sys.version_info < (3, 11), reason="Python 3.11+ is required for async contextvars support", ) class _GraphEventHandler(GraphCallbackHandler): def __init__(self) -> None: self.interrupt_events: list[GraphInterruptEvent] = [] self.resume_events: list[GraphResumeEvent] = [] def on_interrupt(self, event: GraphInterruptEvent) -> Any: self.interrupt_events.append(event) def on_resume(self, event: GraphResumeEvent) -> Any: self.resume_events.append(event) class _LangChainCustomEventHandler(BaseCallbackHandler): run_inline = True def __init__(self) -> None: self.events: list[str] = [] def on_custom_event(self, name: str, data: Any, **kwargs: Any) -> Any: self.events.append(name) class _RaisingGraphEventHandler(GraphCallbackHandler): def __init__( self, *, raise_on_interrupt: bool = False, raise_on_resume: bool = False, raise_error: bool = False, ) -> None: self.raise_on_interrupt = raise_on_interrupt self.raise_on_resume = raise_on_resume self.raise_error = raise_error def on_interrupt(self, event: GraphInterruptEvent) -> Any: if self.raise_on_interrupt: raise ValueError("boom-interrupt") def on_resume(self, event: GraphResumeEvent) -> Any: if self.raise_on_resume: raise ValueError("boom-resume") class _AsyncRaisingGraphEventHandler(GraphCallbackHandler): def __init__( self, *, raise_on_interrupt: bool = False, raise_on_resume: bool = False, raise_error: bool = False, ) -> None: self.raise_on_interrupt = raise_on_interrupt self.raise_on_resume = raise_on_resume self.raise_error = raise_error async def on_interrupt(self, event: GraphInterruptEvent) -> Any: if self.raise_on_interrupt: raise ValueError("boom-interrupt") async def on_resume(self, event: GraphResumeEvent) -> Any: if self.raise_on_resume: raise ValueError("boom-resume") class _State(TypedDict): answer: str | None def _build_interrupt_graph() -> Any: def ask(state: _State) -> _State: answer = interrupt("Provide value") return {"answer": answer} builder = StateGraph(_State) builder.add_node("ask", ask) builder.add_edge(START, "ask") return builder.compile(checkpointer=InMemorySaver()) def test_graph_callbacks_interrupt_and_resume_sync() -> None: graph = _build_interrupt_graph() handler = _GraphEventHandler() langchain_handler = _LangChainCustomEventHandler() config = { "configurable": {"thread_id": "graph-callback-sync"}, "callbacks": [langchain_handler, handler], } first = graph.invoke({"answer": None}, config) assert "__interrupt__" in first assert len(handler.interrupt_events) == 1 assert handler.interrupt_events[0].interrupts assert isinstance(handler.interrupt_events[0].interrupts[0], Interrupt) assert handler.interrupt_events[0].checkpoint_ns == () assert langchain_handler.events == [] handler.resume_events.clear() resumed = graph.invoke(Command(resume="done"), config) assert resumed == {"answer": "done"} assert len(handler.resume_events) == 1 assert handler.resume_events[0].checkpoint_ns == () assert langchain_handler.events == [] @pytest.mark.anyio @NEEDS_CONTEXTVARS async def test_graph_callbacks_interrupt_and_resume_async() -> None: graph = _build_interrupt_graph() handler = _GraphEventHandler() langchain_handler = _LangChainCustomEventHandler() config = { "configurable": {"thread_id": "graph-callback-async"}, "callbacks": [langchain_handler, handler], } first = await graph.ainvoke({"answer": None}, config) assert "__interrupt__" in first assert len(handler.interrupt_events) == 1 assert handler.interrupt_events[0].interrupts assert isinstance(handler.interrupt_events[0].interrupts[0], Interrupt) assert handler.interrupt_events[0].checkpoint_ns == () assert langchain_handler.events == [] handler.resume_events.clear() resumed = await graph.ainvoke(Command(resume="done"), config) assert resumed == {"answer": "done"} assert len(handler.resume_events) == 1 assert handler.resume_events[0].checkpoint_ns == () assert langchain_handler.events == [] def test_graph_callbacks_continue_when_interrupt_handler_raises_sync() -> None: graph = _build_interrupt_graph() raising_handler = _RaisingGraphEventHandler(raise_on_interrupt=True) recording_handler = _GraphEventHandler() first = graph.invoke( {"answer": None}, { "configurable": {"thread_id": "graph-callback-sync-raises"}, "callbacks": [raising_handler, recording_handler], }, ) assert "__interrupt__" in first assert len(recording_handler.interrupt_events) == 1 def test_graph_callbacks_continue_when_resume_handler_raises_sync() -> None: graph = _build_interrupt_graph() raising_handler = _RaisingGraphEventHandler(raise_on_resume=True) recording_handler = _GraphEventHandler() config = { "configurable": {"thread_id": "graph-callback-sync-raises-resume"}, "callbacks": [raising_handler, recording_handler], } first = graph.invoke({"answer": None}, config) assert "__interrupt__" in first resumed = graph.invoke(Command(resume="done"), config) assert resumed == {"answer": "done"} assert len(recording_handler.resume_events) == 1 def test_graph_callbacks_raise_error_propagates_sync() -> None: graph = _build_interrupt_graph() raising_handler = _RaisingGraphEventHandler( raise_on_interrupt=True, raise_error=True, ) with pytest.raises(ValueError, match="boom-interrupt"): graph.invoke( {"answer": None}, { "configurable": {"thread_id": "graph-callback-sync-raise-error"}, "callbacks": [raising_handler], }, ) @pytest.mark.anyio @NEEDS_CONTEXTVARS async def test_graph_callbacks_continue_when_handler_raises_async() -> None: graph = _build_interrupt_graph() raising_interrupt_handler = _AsyncRaisingGraphEventHandler(raise_on_interrupt=True) recording_handler = _GraphEventHandler() config = { "configurable": {"thread_id": "graph-callback-async-raises-interrupt"}, "callbacks": [raising_interrupt_handler, recording_handler], } first = await graph.ainvoke({"answer": None}, config) assert "__interrupt__" in first assert len(recording_handler.interrupt_events) == 1 graph = _build_interrupt_graph() raising_resume_handler = _AsyncRaisingGraphEventHandler(raise_on_resume=True) recording_handler = _GraphEventHandler() config = { "configurable": {"thread_id": "graph-callback-async-raises-resume"}, "callbacks": [raising_resume_handler, recording_handler], } first = await graph.ainvoke({"answer": None}, config) assert "__interrupt__" in first resumed = await graph.ainvoke(Command(resume="done"), config) assert resumed == {"answer": "done"} assert len(recording_handler.resume_events) == 1 @pytest.mark.anyio @NEEDS_CONTEXTVARS async def test_graph_callbacks_raise_error_propagates_async() -> None: graph = _build_interrupt_graph() raising_handler = _AsyncRaisingGraphEventHandler( raise_on_interrupt=True, raise_error=True, ) with pytest.raises(ValueError, match="boom-interrupt"): await graph.ainvoke( {"answer": None}, { "configurable": {"thread_id": "graph-callback-async-raise-error"}, "callbacks": [raising_handler], }, ) def test_graph_callbacks_accept_base_callback_manager() -> None: graph = _build_interrupt_graph() graph_handler = _GraphEventHandler() custom_handler = _LangChainCustomEventHandler() manager = CallbackManager.configure(inheritable_callbacks=[custom_handler]) manager.add_handler(graph_handler) first = graph.invoke( {"answer": None}, { "configurable": {"thread_id": "graph-callback-base-manager"}, "callbacks": manager, }, ) assert "__interrupt__" in first assert len(graph_handler.interrupt_events) == 1 def test_non_graph_handler_via_add_handler_does_not_crash() -> None: """Non-GraphCallbackHandler added via add_handler should not raise. Libraries like opentelemetry-instrumentation-langchain monkey-patch BaseCallbackManager.__init__ and inject handlers via add_handler(). These handlers inherit from BaseCallbackHandler, not GraphCallbackHandler. They must be silently accepted — graph lifecycle events will simply not be dispatched to them. """ from langgraph.callbacks import _GraphCallbackManager manager = _GraphCallbackManager() plain_handler = _LangChainCustomEventHandler() manager.add_handler(plain_handler, inherit=True) assert plain_handler in manager.handlers def test_non_graph_handler_does_not_receive_lifecycle_events() -> None: """Non-GraphCallbackHandler added alongside a GraphCallbackHandler should not interfere with lifecycle event dispatch.""" graph = _build_interrupt_graph() graph_handler = _GraphEventHandler() plain_handler = _LangChainCustomEventHandler() config = { "configurable": {"thread_id": "graph-callback-mixed-handlers"}, "callbacks": [plain_handler, graph_handler], } first = graph.invoke({"answer": None}, config) assert "__interrupt__" in first assert len(graph_handler.interrupt_events) == 1 assert plain_handler.events == [] resumed = graph.invoke(Command(resume="done"), config) assert resumed == {"answer": "done"} assert len(graph_handler.resume_events) == 1 assert plain_handler.events == [] @pytest.mark.anyio @NEEDS_CONTEXTVARS async def test_non_graph_handler_does_not_receive_lifecycle_events_async() -> None: """Async variant: non-GraphCallbackHandler should not interfere.""" graph = _build_interrupt_graph() graph_handler = _GraphEventHandler() plain_handler = _LangChainCustomEventHandler() config = { "configurable": {"thread_id": "graph-callback-mixed-handlers-async"}, "callbacks": [plain_handler, graph_handler], } first = await graph.ainvoke({"answer": None}, config) assert "__interrupt__" in first assert len(graph_handler.interrupt_events) == 1 assert plain_handler.events == [] resumed = await graph.ainvoke(Command(resume="done"), config) assert resumed == {"answer": "done"} assert len(graph_handler.resume_events) == 1 assert plain_handler.events == []