Skip to content

Commit c36ab39

Browse files
fix: update Interrupt object attribute access for LangGraph 1.0+ (bytedance#730) (bytedance#731)
* Update uv.lock to sync with pyproject.toml * fix: update Interrupt object attribute access for LangGraph 1.0+ (bytedance#730) The Interrupt class in LangGraph 1.0 no longer has the 'ns' attribute. This change updates _create_interrupt_event() to use the new 'id' attribute instead, with a fallback to thread_id for compatibility. Changes: - Replace event_data["__interrupt__"][0].ns[0] with interrupt.id - Use getattr() with fallback for backward compatibility - Update debug log message from 'ns=' to 'id=' - Add unit tests for _create_interrupt_event function * fix the unit test error and address review comment --------- Co-authored-by: Willem Jiang <143703838+willem-bd@users.noreply.github.com>
1 parent e1772d5 commit c36ab39

File tree

2 files changed

+105
-6
lines changed

2 files changed

+105
-6
lines changed

src/server/app.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,13 +308,16 @@ def _create_event_stream_message(
308308

309309
def _create_interrupt_event(thread_id, event_data):
310310
"""Create interrupt event."""
311+
interrupt = event_data["__interrupt__"][0]
312+
# Use the 'id' attribute (LangGraph 1.0+) instead of deprecated 'ns[0]'
313+
interrupt_id = getattr(interrupt, "id", None) or thread_id
311314
return _make_event(
312315
"interrupt",
313316
{
314317
"thread_id": thread_id,
315-
"id": event_data["__interrupt__"][0].ns[0],
318+
"id": interrupt_id,
316319
"role": "assistant",
317-
"content": event_data["__interrupt__"][0].value,
320+
"content": interrupt.value,
318321
"finish_reason": "interrupt",
319322
"options": [
320323
{"text": "Edit plan", "value": "edit_plan"},
@@ -461,7 +464,7 @@ async def _stream_graph_events(
461464
if "__interrupt__" in event_data:
462465
logger.debug(
463466
f"[{safe_thread_id}] Processing interrupt event: "
464-
f"ns={getattr(event_data['__interrupt__'][0], 'ns', 'unknown') if isinstance(event_data['__interrupt__'], (list, tuple)) and len(event_data['__interrupt__']) > 0 else 'unknown'}, "
467+
f"id={getattr(event_data['__interrupt__'][0], 'id', 'unknown') if isinstance(event_data['__interrupt__'], (list, tuple)) and len(event_data['__interrupt__']) > 0 else 'unknown'}, "
465468
f"value_len={len(getattr(event_data['__interrupt__'][0], 'value', '')) if isinstance(event_data['__interrupt__'], (list, tuple)) and len(event_data['__interrupt__']) > 0 and hasattr(event_data['__interrupt__'][0], 'value') and hasattr(event_data['__interrupt__'][0].value, '__len__') else 'unknown'}"
466469
)
467470
yield _create_interrupt_event(thread_id, event_data)

tests/unit/server/test_app.py

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
from langgraph.types import Command
1313

1414
from src.config.report_style import ReportStyle
15-
from src.server.app import _astream_workflow_generator, _make_event, app
15+
from src.server.app import (
16+
_astream_workflow_generator,
17+
_create_interrupt_event,
18+
_make_event,
19+
app,
20+
)
1621

1722

1823
@pytest.fixture
@@ -657,9 +662,9 @@ async def mock_astream(*args, **kwargs):
657662
@pytest.mark.asyncio
658663
@patch("src.server.app.graph")
659664
async def test_astream_workflow_generator_interrupt_event(self, mock_graph):
660-
# Mock interrupt data
665+
# Mock interrupt data with the new 'id' attribute (LangGraph 1.0+)
661666
mock_interrupt = MagicMock()
662-
mock_interrupt.ns = ["interrupt_id"]
667+
mock_interrupt.id = "interrupt_id"
663668
mock_interrupt.value = "Plan requires approval"
664669

665670
interrupt_data = {"__interrupt__": [mock_interrupt]}
@@ -920,3 +925,94 @@ def test_generate_prose_error(self, mock_build_graph, client):
920925
response = client.post("/api/prose/generate", json=request_data)
921926
assert response.status_code == 500
922927
assert response.json()["detail"] == "Internal Server Error"
928+
929+
930+
class TestCreateInterruptEvent:
931+
"""Tests for _create_interrupt_event function (Issue #730 fix)."""
932+
933+
def test_create_interrupt_event_with_id_attribute(self):
934+
"""Test that _create_interrupt_event works with LangGraph 1.0+ Interrupt objects that have 'id' attribute."""
935+
# Create a mock Interrupt object with the new 'id' attribute (LangGraph 1.0+)
936+
mock_interrupt = MagicMock()
937+
mock_interrupt.id = "interrupt-123"
938+
mock_interrupt.value = "Please review the research plan"
939+
940+
event_data = {"__interrupt__": [mock_interrupt]}
941+
thread_id = "thread-456"
942+
943+
result = _create_interrupt_event(thread_id, event_data)
944+
945+
# Verify the result is a properly formatted SSE event
946+
assert "event: interrupt\n" in result
947+
assert '"thread_id": "thread-456"' in result
948+
assert '"id": "interrupt-123"' in result
949+
assert '"content": "Please review the research plan"' in result
950+
assert '"finish_reason": "interrupt"' in result
951+
assert '"role": "assistant"' in result
952+
953+
def test_create_interrupt_event_fallback_to_thread_id(self):
954+
"""Test that _create_interrupt_event falls back to thread_id when 'id' attribute is None."""
955+
# Create a mock Interrupt object where id is None
956+
mock_interrupt = MagicMock()
957+
mock_interrupt.id = None
958+
mock_interrupt.value = "Plan review needed"
959+
960+
event_data = {"__interrupt__": [mock_interrupt]}
961+
thread_id = "thread-789"
962+
963+
result = _create_interrupt_event(thread_id, event_data)
964+
965+
# Verify it falls back to thread_id
966+
assert '"id": "thread-789"' in result
967+
assert '"thread_id": "thread-789"' in result
968+
assert '"content": "Plan review needed"' in result
969+
970+
def test_create_interrupt_event_without_id_attribute(self):
971+
"""Test that _create_interrupt_event handles objects without 'id' attribute (backward compatibility)."""
972+
# Create a mock object that doesn't have 'id' attribute at all
973+
class MockInterrupt:
974+
pass
975+
mock_interrupt = MockInterrupt()
976+
mock_interrupt.value = "Waiting for approval"
977+
978+
event_data = {"__interrupt__": [mock_interrupt]}
979+
thread_id = "thread-abc"
980+
981+
result = _create_interrupt_event(thread_id, event_data)
982+
983+
# Verify it falls back to thread_id when id attribute doesn't exist
984+
assert '"id": "thread-abc"' in result
985+
assert '"content": "Waiting for approval"' in result
986+
987+
def test_create_interrupt_event_options(self):
988+
"""Test that _create_interrupt_event includes correct options."""
989+
mock_interrupt = MagicMock()
990+
mock_interrupt.id = "int-001"
991+
mock_interrupt.value = "Review plan"
992+
993+
event_data = {"__interrupt__": [mock_interrupt]}
994+
thread_id = "thread-xyz"
995+
996+
result = _create_interrupt_event(thread_id, event_data)
997+
998+
# Verify options are included
999+
assert '"options":' in result
1000+
assert '"text": "Edit plan"' in result
1001+
assert '"value": "edit_plan"' in result
1002+
assert '"text": "Start research"' in result
1003+
assert '"value": "accepted"' in result
1004+
1005+
def test_create_interrupt_event_with_complex_value(self):
1006+
"""Test that _create_interrupt_event handles complex content values."""
1007+
mock_interrupt = MagicMock()
1008+
mock_interrupt.id = "int-complex"
1009+
mock_interrupt.value = {"plan": "Research AI", "steps": ["step1", "step2"]}
1010+
1011+
event_data = {"__interrupt__": [mock_interrupt]}
1012+
thread_id = "thread-complex"
1013+
1014+
result = _create_interrupt_event(thread_id, event_data)
1015+
1016+
# Verify complex value is included (will be serialized as JSON)
1017+
assert '"id": "int-complex"' in result
1018+
assert "Research AI" in result or "plan" in result

0 commit comments

Comments
 (0)