langgraph-stream-parser 0.4.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/CHANGELOG.md +13 -0
  2. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/PKG-INFO +1 -1
  3. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/pyproject.toml +1 -1
  4. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/agui/__init__.py +54 -4
  5. langgraph_stream_parser-0.4.1/tests/test_agui_matrix.py +261 -0
  6. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/.github/workflows/ci.yml +0 -0
  7. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/.github/workflows/release.yml +0 -0
  8. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/.gitignore +0 -0
  9. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/LICENSE +0 -0
  10. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/README.md +0 -0
  11. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/assets/header.svg +0 -0
  12. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/docs/adr/0001-adopt-ag-ui-for-the-wire.md +0 -0
  13. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/examples/agent.py +0 -0
  14. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/examples/fastapi_websocket.py +0 -0
  15. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/examples/jupyter_example.ipynb +0 -0
  16. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/spec.md +0 -0
  17. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/__init__.py +0 -0
  18. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/adapters/__init__.py +0 -0
  19. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/adapters/base.py +0 -0
  20. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/adapters/cli.py +0 -0
  21. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/adapters/fastapi.py +0 -0
  22. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/adapters/jupyter.py +0 -0
  23. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/adapters/print.py +0 -0
  24. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/adapters/session.py +0 -0
  25. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/agui/__main__.py +0 -0
  26. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/compat.py +0 -0
  27. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/demo/__init__.py +0 -0
  28. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/demo/agent.py +0 -0
  29. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/demo/stub.py +0 -0
  30. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/events.py +0 -0
  31. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/extractors/__init__.py +0 -0
  32. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/extractors/base.py +0 -0
  33. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/extractors/builtins.py +0 -0
  34. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/extractors/interrupts.py +0 -0
  35. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/extractors/messages.py +0 -0
  36. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/handlers/__init__.py +0 -0
  37. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/handlers/messages.py +0 -0
  38. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/handlers/updates.py +0 -0
  39. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/host/__init__.py +0 -0
  40. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/host/__main__.py +0 -0
  41. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/host/config.py +0 -0
  42. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/host/loader.py +0 -0
  43. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/host/workspace.py +0 -0
  44. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/parser.py +0 -0
  45. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/src/langgraph_stream_parser/resume.py +0 -0
  46. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/__init__.py +0 -0
  47. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/fixtures/__init__.py +0 -0
  48. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/fixtures/mocks.py +0 -0
  49. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_agui.py +0 -0
  50. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_cli_adapter.py +0 -0
  51. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_compat.py +0 -0
  52. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_demo.py +0 -0
  53. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_demo_stub.py +0 -0
  54. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_dual_mode.py +0 -0
  55. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_events.py +0 -0
  56. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_extractors.py +0 -0
  57. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_fastapi_adapter.py +0 -0
  58. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_generic_extractor.py +0 -0
  59. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_host.py +0 -0
  60. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_host_config.py +0 -0
  61. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_jupyter.py +0 -0
  62. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_lc14_compat.py +0 -0
  63. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_parser.py +0 -0
  64. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_print_adapter.py +0 -0
  65. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_real_model.py +0 -0
  66. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_reasoning_display.py +0 -0
  67. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_resume.py +0 -0
  68. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_session_adapter.py +0 -0
  69. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_subagent.py +0 -0
  70. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_v2_stream.py +0 -0
  71. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/tests/test_wire_contract.py +0 -0
  72. {langgraph_stream_parser-0.4.0 → langgraph_stream_parser-0.4.1}/uv.lock +0 -0
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.1] - 2026-06-14
4
+
5
+ AG-UI bridge robustness — an edge-case audit (`tests/test_agui_matrix.py`, run against purpose-built tool-calling / interrupting / erroring / checkpointer-less agents) surfaced three real issues, now fixed:
6
+
7
+ ### Fixed
8
+ - **Graphs compiled without a checkpointer no longer hard-crash.** The AG-UI adapter calls `graph.aget_state()`, which raises `No checkpointer set` — common for plain user graphs. `build_agent()` now auto-attaches an in-memory checkpointer when the graph lacks one (AG-UI needs threaded state for interrupts/resume regardless).
9
+ - **Agent exceptions mid-run now emit a terminal `RUN_ERROR`** instead of silently killing the stream / unhandled 500. The endpoint is now a resilient wrapper around the agent run.
10
+
11
+ ### Verified (was previously only claimed)
12
+ - Tool calls map to `TOOL_CALL_START`/`ARGS`/`END` + `TOOL_CALL_RESULT`.
13
+ - Interrupts surface as a `CUSTOM` `on_interrupt` event; resume via `forwardedProps.command.resume` continues the run (HITL round-trip).
14
+ - Multi-turn thread state persists; concurrent requests are isolated (per-request agent clone). Note: AG-UI clients must use **unique message ids** per turn — the adapter dedupes by id.
15
+
3
16
  ## [0.4.0] - 2026-06-14
4
17
 
5
18
  Adopt **AG-UI** for the wire (see `docs/adr/0001-adopt-ag-ui-for-the-wire.md`).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langgraph-stream-parser
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Universal parser + host layer for LangGraph agents — typed stream events, agent-spec loading, layered config, and an AG-UI bridge
5
5
  Project-URL: Homepage, https://github.com/dkedar7/langgraph-stream-parser
6
6
  Project-URL: Documentation, https://github.com/dkedar7/langgraph-stream-parser#readme
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "langgraph-stream-parser"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = "Universal parser + host layer for LangGraph agents — typed stream events, agent-spec loading, layered config, and an AG-UI bridge"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -24,8 +24,9 @@ Quick start::
24
24
  from langgraph_stream_parser.agui import build_app
25
25
  app = build_app(my_compiled_graph) # an ASGI app; run with uvicorn
26
26
  """
27
- from __future__ import annotations
28
-
27
+ # NB: intentionally NOT `from __future__ import annotations`. The resilient
28
+ # endpoint below needs real (non-string) annotations so FastAPI can resolve
29
+ # RunAgentInput as the request body; PEP 604 unions work natively on >=3.11.
29
30
  from typing import Any
30
31
 
31
32
  __all__ = ["build_agent", "add_agui_endpoint", "build_app", "serve", "DEFAULT_AGENT_NAME"]
@@ -71,6 +72,17 @@ def build_agent(
71
72
  from ag_ui_langgraph import LangGraphAgent
72
73
  except ImportError as e: # pragma: no cover - exercised only without the extra
73
74
  raise RuntimeError(_IMPORT_HINT) from e
75
+ # AG-UI requires threaded state — the adapter calls graph.aget_state() and
76
+ # supports interrupts/resume, both of which need a checkpointer. Many user
77
+ # graphs are compiled without one (and would otherwise hard-crash with
78
+ # "No checkpointer set"), so attach an in-memory default when absent.
79
+ if getattr(graph, "checkpointer", None) is None:
80
+ try:
81
+ from langgraph.checkpoint.memory import InMemorySaver
82
+
83
+ graph.checkpointer = InMemorySaver()
84
+ except Exception: # pragma: no cover - best-effort; LangGraphAgent will surface real issues
85
+ pass
74
86
  return LangGraphAgent(name=name, graph=graph, description=description, config=config)
75
87
 
76
88
 
@@ -87,15 +99,53 @@ def add_agui_endpoint(
87
99
 
88
100
  ``graph`` may be a compiled graph or an already-built ``LangGraphAgent``.
89
101
  Returns the same ``app`` for chaining.
102
+
103
+ The endpoint is *resilient*: if the agent raises mid-run, a terminal
104
+ ``RUN_ERROR`` event is emitted and the stream closes cleanly, rather than
105
+ crashing the connection with an unhandled 500 (the bare upstream adapter
106
+ lets node exceptions propagate). Each request runs on its own cloned agent.
90
107
  """
91
108
  try:
92
- from ag_ui_langgraph import add_langgraph_fastapi_endpoint
109
+ from ag_ui.core import EventType, RunAgentInput, RunErrorEvent
110
+ from ag_ui.encoder import EventEncoder
111
+ from fastapi import Request
112
+ from fastapi.responses import StreamingResponse
93
113
  except ImportError as e: # pragma: no cover
94
114
  raise RuntimeError(_IMPORT_HINT) from e
115
+
95
116
  agent = graph if _is_langgraph_agent(graph) else build_agent(
96
117
  graph, name=name, description=description, config=config
97
118
  )
98
- add_langgraph_fastapi_endpoint(app, agent, path=path)
119
+
120
+ @app.post(path)
121
+ async def _run(input_data: RunAgentInput, request: Request):
122
+ accept = request.headers.get("accept")
123
+ try:
124
+ encoder = EventEncoder(accept=accept)
125
+ except TypeError: # pragma: no cover - older SDKs without the accept kwarg
126
+ encoder = EventEncoder()
127
+ media_type = getattr(encoder, "get_content_type", lambda: "text/event-stream")()
128
+ run_agent = agent.clone()
129
+
130
+ async def gen():
131
+ try:
132
+ async for ev in run_agent.run(input_data):
133
+ # run() yields SSE-encoded strings; encode objects defensively.
134
+ yield ev if isinstance(ev, (str, bytes)) else encoder.encode(ev)
135
+ except Exception as exc: # noqa: BLE001 - surfaced to the client as RUN_ERROR
136
+ yield encoder.encode(
137
+ RunErrorEvent(
138
+ type=EventType.RUN_ERROR,
139
+ message=f"{type(exc).__name__}: {exc}",
140
+ )
141
+ )
142
+
143
+ return StreamingResponse(gen(), media_type=media_type)
144
+
145
+ @app.get(path)
146
+ async def _health():
147
+ return {"status": "ok", "agent": {"name": getattr(agent, "name", name)}}
148
+
99
149
  return app
100
150
 
101
151
 
@@ -0,0 +1,261 @@
1
+ """AG-UI edge-case matrix — exercises the bridge against purpose-built agents
2
+ that each stress one event class, asserting the *observed* AG-UI output.
3
+
4
+ This is the audit that converts the langgraph-stream-parser -> AG-UI mapping
5
+ from "claimed" to "proven", and it pins the three robustness fixes the audit
6
+ surfaced:
7
+ 1. graphs compiled WITHOUT a checkpointer are auto-handled (AG-UI's
8
+ aget_state would otherwise hard-crash with "No checkpointer set");
9
+ 2. an agent exception mid-run becomes a terminal RUN_ERROR event instead of
10
+ a silently-dying stream / unhandled 500;
11
+ 3. interrupts surface as a CUSTOM `on_interrupt` event and resume via
12
+ forwardedProps.command.resume.
13
+
14
+ Every agent here is compiled WITHOUT a checkpointer on purpose, so the suite
15
+ also proves the auto-attach fix end to end.
16
+ """
17
+ import json
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from typing import Iterator, List
20
+
21
+ import pytest
22
+
23
+ pytest.importorskip("ag_ui_langgraph", reason="needs the 'agui' extra")
24
+ pytest.importorskip("fastapi", reason="needs fastapi")
25
+ pytest.importorskip("langgraph", reason="needs langgraph")
26
+
27
+ from fastapi.testclient import TestClient # noqa: E402
28
+ from langchain_core.language_models import BaseChatModel # noqa: E402
29
+ from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage # noqa: E402
30
+ from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult # noqa: E402
31
+ from langchain_core.tools import tool # noqa: E402
32
+ from langgraph.graph import END, START, MessagesState, StateGraph # noqa: E402
33
+ from langgraph.prebuilt import ToolNode # noqa: E402
34
+ from langgraph.types import interrupt # noqa: E402
35
+
36
+ from langgraph_stream_parser.agui import build_app # noqa: E402
37
+ from langgraph_stream_parser.demo import create_stub_agent # noqa: E402
38
+
39
+
40
+ # ── driver ───────────────────────────────────────────────────────────
41
+
42
+ def _run_input(text, *, thread="t1", run="r1", resume=None):
43
+ # NB: each turn needs a UNIQUE message id — the AG-UI/LangGraph adapter
44
+ # dedupes messages by id, so reusing an id silently drops later turns.
45
+ body = {
46
+ "threadId": thread, "runId": run,
47
+ "messages": [{"id": f"msg-{thread}-{run}", "role": "user", "content": text}],
48
+ "tools": [], "context": [], "state": {}, "forwardedProps": {},
49
+ }
50
+ if resume is not None:
51
+ body["forwardedProps"] = {"command": {"resume": resume}}
52
+ return body
53
+
54
+
55
+ def _drive(app, body):
56
+ """POST a run, return (status_code, [event dicts])."""
57
+ client = TestClient(app, raise_server_exceptions=False)
58
+ resp = client.post("/", json=body)
59
+ events = []
60
+ for line in resp.text.splitlines():
61
+ line = line.strip()
62
+ if line.startswith("data:"):
63
+ try:
64
+ events.append(json.loads(line[5:].strip()))
65
+ except json.JSONDecodeError:
66
+ pass
67
+ return resp.status_code, events
68
+
69
+
70
+ def _types(events):
71
+ return [e.get("type") for e in events]
72
+
73
+
74
+ def _text(events):
75
+ return "".join(e.get("delta", "") for e in events if e.get("type") == "TEXT_MESSAGE_CONTENT")
76
+
77
+
78
+ # ── purpose-built agents (all compiled WITHOUT a checkpointer) ─────────
79
+
80
+ @tool
81
+ def add(a: int, b: int) -> int:
82
+ """Add two integers."""
83
+ return a + b
84
+
85
+
86
+ class _ToolThenAnswer(BaseChatModel):
87
+ @property
88
+ def _llm_type(self) -> str:
89
+ return "tool-matrix-stub"
90
+
91
+ def bind_tools(self, *a, **k):
92
+ return self
93
+
94
+ def _generate(self, messages, stop=None, run_manager=None, **kw):
95
+ return ChatResult(generations=[ChatGeneration(message=self._reply(messages))])
96
+
97
+ def _reply(self, messages: List[BaseMessage]) -> AIMessage:
98
+ if messages and getattr(messages[-1], "type", None) == "tool":
99
+ return AIMessage(content="the sum is 5")
100
+ return AIMessage(content="", tool_calls=[{"id": "call_1", "name": "add", "args": {"a": 2, "b": 3}}])
101
+
102
+ def _stream(self, messages, stop=None, run_manager=None, **kw) -> Iterator[ChatGenerationChunk]:
103
+ if messages and getattr(messages[-1], "type", None) == "tool":
104
+ for tok in ["the ", "sum ", "is ", "5"]:
105
+ ch = ChatGenerationChunk(message=AIMessageChunk(content=tok))
106
+ if run_manager:
107
+ run_manager.on_llm_new_token(tok, chunk=ch)
108
+ yield ch
109
+ else:
110
+ yield ChatGenerationChunk(message=AIMessageChunk(
111
+ content="",
112
+ tool_call_chunks=[{"name": "add", "args": '{"a": 2, "b": 3}', "id": "call_1", "index": 0}],
113
+ ))
114
+
115
+
116
+ def _tool_agent():
117
+ model = _ToolThenAnswer()
118
+
119
+ def call_model(state):
120
+ return {"messages": [model.invoke(state["messages"])]}
121
+
122
+ def route(state):
123
+ return "tools" if getattr(state["messages"][-1], "tool_calls", None) else END
124
+
125
+ b = StateGraph(MessagesState)
126
+ b.add_node("model", call_model)
127
+ b.add_node("tools", ToolNode([add]))
128
+ b.add_edge(START, "model")
129
+ b.add_conditional_edges("model", route, {"tools": "tools", END: END})
130
+ b.add_edge("tools", "model")
131
+ return b.compile() # NO checkpointer — exercises the auto-attach fix
132
+
133
+
134
+ def _interrupt_agent():
135
+ def ask(state):
136
+ decision = interrupt({"question": "approve?", "tool": "deploy"})
137
+ return {"messages": [AIMessage(content=f"resumed: {decision}")]}
138
+
139
+ b = StateGraph(MessagesState)
140
+ b.add_node("ask", ask)
141
+ b.add_edge(START, "ask")
142
+ b.add_edge("ask", END)
143
+ return b.compile() # NO checkpointer
144
+
145
+
146
+ def _error_agent():
147
+ def boom(state):
148
+ raise ValueError("intentional boom")
149
+
150
+ b = StateGraph(MessagesState)
151
+ b.add_node("boom", boom)
152
+ b.add_edge(START, "boom")
153
+ b.add_edge("boom", END)
154
+ return b.compile() # NO checkpointer
155
+
156
+
157
+ def _plain_echo_no_checkpointer():
158
+ """An echo graph with NO checkpointer (create_stub_agent ships one)."""
159
+ from langchain_core.messages import AIMessage as _AI
160
+
161
+ def respond(state):
162
+ last = next((m.content for m in reversed(state["messages"])
163
+ if getattr(m, "type", None) == "human"), "")
164
+ return {"messages": [_AI(content=f"echo: {last}")]}
165
+
166
+ b = StateGraph(MessagesState)
167
+ b.add_node("respond", respond)
168
+ b.add_edge(START, "respond")
169
+ b.add_edge("respond", END)
170
+ return b.compile()
171
+
172
+
173
+ # ── tests ────────────────────────────────────────────────────────────
174
+
175
+ def test_tool_calls_map_to_agui():
176
+ status, events = _drive(build_app(_tool_agent()), _run_input("add 2 and 3"))
177
+ assert status == 200
178
+ t = _types(events)
179
+ assert "TOOL_CALL_START" in t
180
+ assert "TOOL_CALL_RESULT" in t
181
+ start = next(e for e in events if e["type"] == "TOOL_CALL_START")
182
+ result = next(e for e in events if e["type"] == "TOOL_CALL_RESULT")
183
+ assert start.get("toolCallName") == "add"
184
+ assert result.get("content") == "5"
185
+ assert "the sum is 5" in _text(events)
186
+ assert "RUN_FINISHED" in t and "RUN_ERROR" not in t
187
+
188
+
189
+ def test_interrupt_is_signaled_and_resumes():
190
+ app = build_app(_interrupt_agent())
191
+
192
+ status, first = _drive(app, _run_input("go", thread="ti", run="r1"))
193
+ assert status == 200
194
+ customs = [e for e in first if e.get("type") == "CUSTOM" and e.get("name") == "on_interrupt"]
195
+ assert customs, f"interrupt not signaled as CUSTOM/on_interrupt: {_types(first)}"
196
+ # the interrupt payload travels to the client
197
+ assert "approve?" in json.dumps(customs[0])
198
+ assert "RUN_ERROR" not in _types(first)
199
+
200
+ # resume the SAME thread with a decision via forwardedProps.command.resume
201
+ status, second = _drive(app, _run_input("", thread="ti", run="r2", resume="approved"))
202
+ assert status == 200
203
+ assert "RUN_FINISHED" in _types(second)
204
+ assert "RUN_ERROR" not in _types(second)
205
+ # the resumed value reached the agent (it echoed "resumed: approved" into state)
206
+ assert "resumed: approved" in json.dumps(second)
207
+
208
+
209
+ def test_agent_error_becomes_run_error():
210
+ """The resilient endpoint emits a terminal RUN_ERROR instead of a
211
+ silently-dying stream / unhandled 500."""
212
+ status, events = _drive(build_app(_error_agent()), _run_input("crash"))
213
+ assert status == 200
214
+ errs = [e for e in events if e.get("type") == "RUN_ERROR"]
215
+ assert errs, f"expected a RUN_ERROR event, got: {_types(events)}"
216
+ assert "intentional boom" in errs[0].get("message", "")
217
+
218
+
219
+ def test_graph_without_checkpointer_is_handled():
220
+ """AG-UI's aget_state needs a checkpointer; the bridge auto-attaches one
221
+ so a plain compiled graph doesn't hard-crash."""
222
+ status, events = _drive(build_app(_plain_echo_no_checkpointer()), _run_input("ping"))
223
+ assert status == 200
224
+ # The fix's job: no hard-crash, run completes. (This node appends an
225
+ # AIMessage directly rather than streaming a model, so its content lands in
226
+ # the state/messages snapshot, not as TEXT_MESSAGE_CONTENT deltas.)
227
+ assert "RUN_ERROR" not in _types(events)
228
+ assert "RUN_FINISHED" in _types(events)
229
+ assert "echo: ping" in json.dumps(events)
230
+
231
+
232
+ def test_multi_turn_thread_persists():
233
+ app = build_app(create_stub_agent(reply_prefix="echo: "))
234
+ s1, e1 = _drive(app, _run_input("first", thread="conv", run="r1"))
235
+ s2, e2 = _drive(app, _run_input("second", thread="conv", run="r2"))
236
+ assert s1 == 200 and s2 == 200
237
+ assert "first" in _text(e1)
238
+ assert "second" in _text(e2)
239
+ # state persisted across turns: the second run carries BOTH turns
240
+ blob = json.dumps(e2)
241
+ assert "first" in blob and "second" in blob
242
+
243
+
244
+ def test_concurrent_requests_are_isolated():
245
+ """The adapter clones per request; distinct threads must not cross-talk."""
246
+ app = build_app(create_stub_agent(reply_prefix="echo: "))
247
+ inputs = [f"msg-{i}" for i in range(8)]
248
+
249
+ def one(i):
250
+ _, events = _drive(app, _run_input(inputs[i], thread=f"th-{i}", run=f"r-{i}"))
251
+ return inputs[i], _text(events)
252
+
253
+ with ThreadPoolExecutor(max_workers=8) as ex:
254
+ results = list(ex.map(one, range(8)))
255
+
256
+ for sent, got in results:
257
+ assert sent in got, f"{sent!r} not echoed in {got!r}"
258
+ # no other request's payload leaked into this response
259
+ for other in inputs:
260
+ if other != sent:
261
+ assert other not in got, f"cross-talk: {other!r} leaked into {got!r}"