graphharbor 0.13.0.post35__tar.gz → 0.13.0.post37__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 (23) hide show
  1. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/PKG-INFO +2 -2
  2. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/pyproject.toml +2 -2
  3. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/core_api.py +54 -17
  4. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/protocol_api.py +40 -17
  5. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_stream_heartbeat_resilience.py +92 -0
  6. graphharbor-0.13.0.post37/tests/test_subagent_history.py +213 -0
  7. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/.gitignore +0 -0
  8. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/LICENSE +0 -0
  9. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/README.md +0 -0
  10. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/__init__.py +0 -0
  11. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/__main__.py +0 -0
  12. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/cli.py +0 -0
  13. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/mcp_transport.py +0 -0
  14. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/server.py +0 -0
  15. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/store_api.py +0 -0
  16. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/src/langhost/streaming.py +0 -0
  17. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_application_authorization.py +0 -0
  18. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_cli.py +0 -0
  19. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_graph_discovery.py +0 -0
  20. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_mcp_transport.py +0 -0
  21. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_official_protocol_compare.py +0 -0
  22. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_server_paths.py +0 -0
  23. {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post37}/tests/test_thread_state_projection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphharbor
3
- Version: 0.13.0.post35
3
+ Version: 0.13.0.post37
4
4
  Summary: GraphHarbor CLI for a self-hosted LangGraph Agent Server on PostgreSQL and Redis
5
5
  Project-URL: Homepage, https://github.com/ljxpython/graphharbor
6
6
  Project-URL: Repository, https://github.com/ljxpython/graphharbor
@@ -26,7 +26,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
26
  Classifier: Typing :: Typed
27
27
  Requires-Python: >=3.11
28
28
  Requires-Dist: click>=8.1.7
29
- Requires-Dist: graphharbor-runtime==0.13.0.post35
29
+ Requires-Dist: graphharbor-runtime==0.13.0.post37
30
30
  Requires-Dist: langgraph-cli<0.5,>=0.4.0
31
31
  Requires-Dist: mcp<2,>=1.23
32
32
  Requires-Dist: pyfiglet>=1.0.0
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "graphharbor"
3
3
  # Lockstep with graphharbor-runtime; independent of langgraph-api releases.
4
- version = "0.13.0.post35"
4
+ version = "0.13.0.post37"
5
5
  description = "GraphHarbor CLI for a self-hosted LangGraph Agent Server on PostgreSQL and Redis"
6
6
  readme = "README.md"
7
7
  license = "MIT"
@@ -50,7 +50,7 @@ dependencies = [
50
50
  "mcp>=1.23,<2",
51
51
  "uvicorn[standard]>=0.51.0",
52
52
  # Exact same version as graphharbor-runtime (lockstep releases).
53
- "graphharbor-runtime==0.13.0.post35",
53
+ "graphharbor-runtime==0.13.0.post37",
54
54
  ]
55
55
 
56
56
  [project.urls]
@@ -858,10 +858,16 @@ async def threads_delete(request: Request) -> JSONResponse | Response:
858
858
  return _no_content()
859
859
 
860
860
 
861
- def _checkpoint_config(thread_id: UUID, checkpoint_id: str | None = None) -> dict[str, Any]:
861
+ def _checkpoint_config(
862
+ thread_id: UUID,
863
+ checkpoint_id: str | None = None,
864
+ checkpoint_ns: str | None = None,
865
+ ) -> dict[str, Any]:
862
866
  configurable: dict[str, Any] = {"thread_id": str(thread_id)}
863
867
  if checkpoint_id:
864
868
  configurable["checkpoint_id"] = checkpoint_id
869
+ if checkpoint_ns is not None:
870
+ configurable["checkpoint_ns"] = checkpoint_ns
865
871
  return {"configurable": configurable}
866
872
 
867
873
 
@@ -936,57 +942,73 @@ async def threads_state(request: Request) -> JSONResponse:
936
942
  if row is None or thread_id is None:
937
943
  return _error("thread not found", 404)
938
944
  checkpoint_id = request.path_params.get("checkpoint_id")
939
- if not checkpoint_id and request.method == "POST":
945
+ checkpoint_ns = request.query_params.get("checkpoint_ns")
946
+ if request.method == "POST":
940
947
  try:
941
948
  body = await request.json()
942
949
  if isinstance(body, dict):
943
950
  cp_data = body.get("checkpoint")
944
951
  if isinstance(cp_data, dict):
945
- checkpoint_id = cp_data.get("checkpoint_id")
946
- elif isinstance(cp_data, str):
952
+ if not checkpoint_id:
953
+ checkpoint_id = cp_data.get("checkpoint_id")
954
+ if not checkpoint_ns:
955
+ checkpoint_ns = cp_data.get("checkpoint_ns")
956
+ elif isinstance(cp_data, str) and not checkpoint_id:
947
957
  checkpoint_id = cp_data
948
958
  if not checkpoint_id:
949
959
  checkpoint_id = body.get("checkpoint_id")
960
+ if not checkpoint_ns:
961
+ checkpoint_ns = body.get("checkpoint_ns")
950
962
  except Exception:
951
963
  pass
952
964
 
965
+ is_subgraph = bool(checkpoint_ns)
966
+ config = cast(
967
+ RunnableConfig,
968
+ _checkpoint_config(thread_id, checkpoint_id=checkpoint_id, checkpoint_ns=checkpoint_ns),
969
+ )
953
970
  registry = getattr(request.app.state, "graph_registry", None)
954
971
  graph_id = row.graph_id or row.metadata_.get("graph_id")
955
- if registry is not None and graph_id and str(graph_id) in registry.ids():
956
- config = cast(RunnableConfig, _checkpoint_config(thread_id, checkpoint_id))
972
+ # Subgraphs invoked dynamically within tools do not exist in the root graph's
973
+ # static topology; querying the compiled graph would raise SubgraphNotFound.
974
+ # Route subgraphs directly to the persistent checkpointer.
975
+ if not is_subgraph and registry is not None and graph_id and str(graph_id) in registry.ids():
957
976
  try:
958
977
  async with registry.open(str(graph_id), config) as graph:
959
978
  return JSONResponse(_state_from_snapshot(await graph.aget_state(config)))
960
979
  except Exception as exc:
961
980
  return _error(f"checkpoint read failed: {exc}", 503)
962
981
  try:
963
- item = await get_checkpointer().aget_tuple(
964
- cast(RunnableConfig, _checkpoint_config(thread_id, checkpoint_id))
965
- )
982
+ item = await get_checkpointer().aget_tuple(config)
966
983
  except Exception as exc:
967
984
  return _error(f"checkpoint read failed: {exc}", 503)
968
985
  if item is None:
969
986
  return JSONResponse(
970
987
  _plain(
971
988
  {
972
- "values": row.values_ or {},
989
+ "values": {} if is_subgraph else (row.values_ or {}),
973
990
  "next": [],
974
- "checkpoint": _checkpoint_key(_checkpoint_config(thread_id, checkpoint_id)),
991
+ "checkpoint": _checkpoint_key(config),
975
992
  "metadata": {},
976
993
  "created_at": None,
977
994
  "parent_checkpoint": None,
978
995
  "tasks": [],
979
- "interrupts": _stored_interrupts(row),
996
+ "interrupts": [] if is_subgraph else _stored_interrupts(row),
980
997
  }
981
998
  )
982
999
  )
983
1000
  state = _state_from_tuple(item)
984
- if not state.get("interrupts"):
1001
+ if not is_subgraph and not state.get("interrupts"):
985
1002
  state["interrupts"] = _stored_interrupts(row)
986
1003
  # Some LangChain/Deep Agents checkpoints keep only scheduler bookkeeping in
987
1004
  # channel_values while the durable thread row has the final projected state.
988
1005
  # Expose that state instead of returning the misleading bare __pregel_tasks.
989
- if checkpoint_id is None and not _has_projected_values(state.get("values")) and row.values_:
1006
+ if (
1007
+ not is_subgraph
1008
+ and checkpoint_id is None
1009
+ and not _has_projected_values(state.get("values"))
1010
+ and row.values_
1011
+ ):
990
1012
  state["values"] = _plain(row.values_)
991
1013
  return JSONResponse(state)
992
1014
 
@@ -1002,7 +1024,18 @@ async def threads_history(request: Request) -> JSONResponse:
1002
1024
  limit, _ = _pagination(request, payload)
1003
1025
  except ValueError as exc:
1004
1026
  return _error(str(exc))
1005
- config = cast(RunnableConfig, _checkpoint_config(thread_id))
1027
+
1028
+ checkpoint_ns: str | None = None
1029
+ if request.method == "POST":
1030
+ cp_data = payload.get("checkpoint")
1031
+ if isinstance(cp_data, dict):
1032
+ checkpoint_ns = cp_data.get("checkpoint_ns")
1033
+ if not checkpoint_ns:
1034
+ checkpoint_ns = payload.get("checkpoint_ns")
1035
+ if not checkpoint_ns:
1036
+ checkpoint_ns = request.query_params.get("checkpoint_ns")
1037
+
1038
+ config = cast(RunnableConfig, _checkpoint_config(thread_id, checkpoint_ns=checkpoint_ns))
1006
1039
  before = (
1007
1040
  payload.get("before") if request.method == "POST" else request.query_params.get("before")
1008
1041
  )
@@ -1011,12 +1044,16 @@ async def threads_history(request: Request) -> JSONResponse:
1011
1044
  before = json.loads(before)
1012
1045
  except (TypeError, json.JSONDecodeError):
1013
1046
  return _error("before must be a JSON object")
1014
- if isinstance(before, dict):
1047
+ if isinstance(before, str):
1048
+ before = {"configurable": {"checkpoint_id": before}}
1049
+ elif isinstance(before, dict):
1015
1050
  before = {"configurable": before}
1016
1051
  before_config = cast(RunnableConfig | None, before)
1052
+
1053
+ is_subgraph = bool(checkpoint_ns)
1017
1054
  registry = getattr(request.app.state, "graph_registry", None)
1018
1055
  graph_id = row.graph_id or row.metadata_.get("graph_id")
1019
- if registry is not None and graph_id and str(graph_id) in registry.ids():
1056
+ if not is_subgraph and registry is not None and graph_id and str(graph_id) in registry.ids():
1020
1057
  try:
1021
1058
  async with registry.open(str(graph_id), config) as graph:
1022
1059
  return JSONResponse(
@@ -264,10 +264,15 @@ async def _load_protocol_events(thread_id: UUID, since: int) -> tuple[int, list[
264
264
 
265
265
 
266
266
  def _wire_matches(wire: dict[str, Any], body: dict[str, Any]) -> bool:
267
- method = str(wire.get("method", ""))
268
- params = wire.get("params") or {}
267
+ if not isinstance(wire, dict):
268
+ return False
269
+ method = str(wire.get("method") or "")
270
+ params = wire.get("params")
271
+ params_dict = params if isinstance(params, dict) else {}
272
+ ns = params_dict.get("namespace")
273
+ ns_list = list(ns) if isinstance(ns, (list, tuple)) else []
269
274
  return _channel_matches(method, body.get("channels")) and _namespace_matches(
270
- list(params.get("namespace") or []), body.get("namespaces"), body.get("depth")
275
+ ns_list, body.get("namespaces"), body.get("depth")
271
276
  )
272
277
 
273
278
 
@@ -321,36 +326,54 @@ async def protocol_event_stream(request: Request) -> JSONResponse | StreamingRes
321
326
  {"code": "cursor_expired", "detail": "cursor_expired", "recovery": "thread_snapshot"},
322
327
  status_code=410,
323
328
  )
324
- parsed_run_ids = {
325
- uid
326
- for wire in replay
327
- if (uid := _safe_uuid(wire.get("params", {}).get("run_id"))) is not None
328
- }
329
+ parsed_run_ids: set[UUID] = set()
330
+ for wire in replay:
331
+ if isinstance(wire, dict):
332
+ params = wire.get("params")
333
+ if isinstance(params, dict):
334
+ uid = _safe_uuid(params.get("run_id"))
335
+ if uid is not None:
336
+ parsed_run_ids.add(uid)
329
337
  resumable = await _resumable_run_ids(parsed_run_ids)
330
338
  active_interrupt_ids = (
331
339
  set(thread.interrupts.keys()) if isinstance(thread.interrupts, dict) else set()
332
340
  )
333
341
 
334
- def _is_active_protocol_event(w: dict[str, Any]) -> bool:
335
- method = w.get("method")
336
- params = w.get("params") or {}
337
- data = params.get("data") or {}
342
+ def _is_active_protocol_event(w: Any) -> bool:
343
+ if not isinstance(w, dict):
344
+ return True
345
+ method = str(w.get("method") or "")
346
+ params = w.get("params")
347
+ params_dict = params if isinstance(params, dict) else {}
348
+ data = params_dict.get("data")
349
+ data_dict = data if isinstance(data, dict) else {}
350
+ data_event = str(data_dict.get("event") or "")
351
+
338
352
  is_input_req = (
339
353
  method == "input.requested"
340
- or (method == "input" and data.get("event") == "requested")
341
- or data.get("event") == "input.requested"
354
+ or (method == "input" and data_event == "requested")
355
+ or data_event == "input.requested"
342
356
  )
343
357
  if is_input_req:
344
- iid = str(data.get("interrupt_id") or params.get("interrupt_id") or "")
358
+ iid = str(data_dict.get("interrupt_id") or params_dict.get("interrupt_id") or "")
345
359
  if iid and iid not in active_interrupt_ids:
346
360
  return False
347
361
  return True
348
362
 
363
+ def _is_resumable(wire: Any) -> bool:
364
+ if not isinstance(wire, dict):
365
+ return True
366
+ params = wire.get("params")
367
+ if isinstance(params, dict):
368
+ uid = _safe_uuid(params.get("run_id"))
369
+ if uid is not None and uid not in resumable:
370
+ return False
371
+ return True
372
+
349
373
  replay = [
350
374
  wire
351
375
  for wire in replay
352
- if ((uid := _safe_uuid(wire.get("params", {}).get("run_id"))) is None or uid in resumable)
353
- and _is_active_protocol_event(wire)
376
+ if _is_resumable(wire) and _is_active_protocol_event(wire)
354
377
  ]
355
378
  except Exception:
356
379
  await manager.remove_thread_stream(thread_id, queue)
@@ -342,3 +342,95 @@ async def test_zombie_interrupt_replay_filtered(monkeypatch):
342
342
  assert "int-old" not in all_output, "Zombie interrupt 'int-old' must NOT be replayed to client!"
343
343
  assert "int-active" in all_output, "Active interrupt 'int-active' must be replayed to client!"
344
344
 
345
+
346
+ @pytest.mark.asyncio
347
+ async def test_protocol_stream_replay_with_non_dict_data_payloads(monkeypatch):
348
+ """Ensure protocol stream replay handles events where data is a list, string, or params is malformed."""
349
+ thread_id = uuid4()
350
+ run_id = uuid4()
351
+
352
+ mock_thread = ThreadRow(
353
+ thread_id=thread_id,
354
+ status="busy",
355
+ metadata_={},
356
+ config={},
357
+ values_={},
358
+ interrupts={},
359
+ error=None,
360
+ )
361
+
362
+ manager = InMemoryStreamManager()
363
+ monkeypatch.setattr("langhost.protocol_api.get_stream_manager", lambda: manager)
364
+ monkeypatch.setattr(
365
+ "langhost.protocol_api._thread",
366
+ lambda *a, **k: asyncio.sleep(0, result=mock_thread),
367
+ )
368
+ monkeypatch.setattr(
369
+ "langhost.streaming._resumable_run_ids",
370
+ lambda rids: asyncio.sleep(0, result=rids),
371
+ )
372
+
373
+ # Various weird payloads that occur in real production (list data, none params, string data)
374
+ wire_list_data = {
375
+ "type": "event",
376
+ "event_id": "evt-list",
377
+ "seq": 1,
378
+ "method": "messages",
379
+ "params": {
380
+ "run_id": str(run_id),
381
+ "thread_id": str(thread_id),
382
+ "namespace": [],
383
+ "data": [{"delta": {"type": "block-delta", "text": "hello"}}],
384
+ },
385
+ }
386
+ wire_string_data = {
387
+ "type": "event",
388
+ "event_id": "evt-str",
389
+ "seq": 2,
390
+ "method": "custom",
391
+ "params": {
392
+ "run_id": str(run_id),
393
+ "thread_id": str(thread_id),
394
+ "namespace": [],
395
+ "data": "plain string",
396
+ },
397
+ }
398
+ wire_none_params = {
399
+ "type": "event",
400
+ "event_id": "evt-none-params",
401
+ "seq": 3,
402
+ "method": "custom",
403
+ "params": None,
404
+ }
405
+
406
+ monkeypatch.setattr(
407
+ "langhost.protocol_api._load_protocol_events",
408
+ lambda *a, **k: asyncio.sleep(
409
+ 0, result=(0, [wire_list_data, wire_string_data, wire_none_params])
410
+ ),
411
+ )
412
+
413
+ scope, receive = _make_scope_and_receive(
414
+ "POST",
415
+ f"/threads/{thread_id}/stream/events",
416
+ {"thread_id": str(thread_id)},
417
+ body_dict={"channels": ["messages", "custom"], "since": 0},
418
+ )
419
+ req = Request(scope, receive)
420
+ res = await protocol_event_stream(req)
421
+ assert isinstance(res, StreamingResponse)
422
+
423
+ frames: list[str] = []
424
+ try:
425
+ async for chunk in res.body_iterator:
426
+ frames.append(chunk)
427
+ if len(frames) >= 2:
428
+ break
429
+ finally:
430
+ await res.body_iterator.aclose()
431
+
432
+ all_output = "".join(frames)
433
+ assert "evt-list" in all_output
434
+ assert "evt-str" in all_output
435
+
436
+
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from contextlib import asynccontextmanager
5
+ from types import SimpleNamespace
6
+ from typing import Annotated, TypedDict
7
+ from uuid import uuid4
8
+
9
+ import pytest
10
+ from langgraph.channels import DeltaChannel
11
+ from langgraph.checkpoint.base import empty_checkpoint
12
+ from langgraph.checkpoint.memory import InMemorySaver
13
+ from langgraph.graph import END, START, StateGraph
14
+ from starlette.requests import Request
15
+
16
+ from langhost import core_api
17
+
18
+
19
+ class RootState(TypedDict):
20
+ messages: Annotated[
21
+ list[str],
22
+ DeltaChannel(lambda state, writes: state + [item for batch in writes for item in batch]),
23
+ ]
24
+
25
+
26
+ @pytest.mark.asyncio
27
+ async def test_subagent_namespace_state_and_history(monkeypatch):
28
+ tid = uuid4()
29
+ root_config = {"configurable": {"thread_id": str(tid), "checkpoint_ns": ""}}
30
+ subagent_ns = "tools:call_subagent_abc123"
31
+
32
+ saver = InMemorySaver()
33
+
34
+ # 1. 模拟根图执行,保存在根命名空间
35
+ root_graph = (
36
+ StateGraph(RootState)
37
+ .add_node("agent", lambda state: {"messages": ["call task tool"]})
38
+ .add_edge(START, "agent")
39
+ .add_edge("agent", END)
40
+ .compile(checkpointer=saver)
41
+ )
42
+ await root_graph.ainvoke({"messages": ["user prompt"]}, root_config)
43
+
44
+ # 2. 模拟 Subagent 在独立命名空间下的执行历史(写入多个 step checkpoint)
45
+ # Step 1: Subagent 接收指令并发出 read_file 工具调用
46
+ cp_id_1 = "00000000-0000-0000-0000-000000000001"
47
+ config_step1 = {
48
+ "configurable": {
49
+ "thread_id": str(tid),
50
+ "checkpoint_ns": subagent_ns,
51
+ "checkpoint_id": cp_id_1,
52
+ }
53
+ }
54
+ cp1 = empty_checkpoint()
55
+ cp1["id"] = cp_id_1
56
+ cp1["channel_values"] = {
57
+ "messages": [
58
+ {"role": "human", "content": "investigate code"},
59
+ {"role": "ai", "tool_calls": [{"name": "read_file", "args": {"path": "main.py"}}]},
60
+ ]
61
+ }
62
+ cp1["channel_versions"] = {"messages": 1}
63
+ await saver.aput(config_step1, cp1, {"step": 1}, {"messages": 1})
64
+
65
+ # Step 2: Subagent 获得 read_file 返回并给出最终结论
66
+ cp_id_2 = "00000000-0000-0000-0000-000000000002"
67
+ config_step2 = {
68
+ "configurable": {
69
+ "thread_id": str(tid),
70
+ "checkpoint_ns": subagent_ns,
71
+ "checkpoint_id": cp_id_2,
72
+ }
73
+ }
74
+ cp2 = empty_checkpoint()
75
+ cp2["id"] = cp_id_2
76
+ cp2["channel_values"] = {
77
+ "messages": [
78
+ {"role": "human", "content": "investigate code"},
79
+ {"role": "ai", "tool_calls": [{"name": "read_file", "args": {"path": "main.py"}}]},
80
+ {"role": "tool", "name": "read_file", "content": "def main(): pass"},
81
+ {"role": "ai", "content": "investigation finished"},
82
+ ]
83
+ }
84
+ cp2["channel_versions"] = {"messages": 2}
85
+ await saver.aput(config_step2, cp2, {"step": 2}, {"messages": 2})
86
+
87
+ # 3. 准备 mock 的 graph_registry 与 get_checkpointer
88
+ @asynccontextmanager
89
+ async def opened(graph_id, run_config):
90
+ # 静态注册中只有 root_graph,子图在 registry 中并不存在
91
+ assert graph_id == "root_agent"
92
+ yield root_graph
93
+
94
+ async def get_thread(request):
95
+ return (
96
+ SimpleNamespace(
97
+ graph_id="root_agent",
98
+ metadata_={},
99
+ values_={"messages": ["root cached messages"]},
100
+ interrupts={},
101
+ ),
102
+ None,
103
+ tid,
104
+ )
105
+
106
+ monkeypatch.setattr(core_api, "_get_thread", get_thread)
107
+ monkeypatch.setattr(core_api, "get_checkpointer", lambda: saver)
108
+
109
+ app = SimpleNamespace(
110
+ state=SimpleNamespace(
111
+ graph_registry=SimpleNamespace(open=opened, ids=lambda: ("root_agent",))
112
+ )
113
+ )
114
+
115
+ # 4. 场景 1:根图默认查询(回归测试)
116
+ req_root = Request(
117
+ {
118
+ "type": "http",
119
+ "method": "GET",
120
+ "app": app,
121
+ "path_params": {},
122
+ "query_string": b"",
123
+ }
124
+ )
125
+ root_state = json.loads((await core_api.threads_state(req_root)).body)
126
+ assert root_state["checkpoint"]["checkpoint_ns"] == ""
127
+ assert root_state["values"]["messages"] == ["user prompt", "call task tool"]
128
+
129
+ # 5. 场景 2:通过 POST /state/checkpoint 指定子图 checkpoint_ns 查询最新状态
130
+ req_sub_post = Request(
131
+ {
132
+ "type": "http",
133
+ "method": "POST",
134
+ "app": app,
135
+ "path_params": {},
136
+ "query_string": b"",
137
+ }
138
+ )
139
+ req_sub_post._json = {"checkpoint": {"checkpoint_ns": subagent_ns}}
140
+ sub_state_post = json.loads((await core_api.threads_state(req_sub_post)).body)
141
+ assert sub_state_post["checkpoint"]["checkpoint_ns"] == subagent_ns
142
+ assert sub_state_post["checkpoint"]["checkpoint_id"] == cp_id_2
143
+ assert len(sub_state_post["values"]["messages"]) == 4
144
+ assert sub_state_post["values"]["messages"][1]["tool_calls"][0]["name"] == "read_file"
145
+ assert sub_state_post["values"]["messages"][3]["content"] == "investigation finished"
146
+
147
+ # 6. 场景 3:通过 GET /state?checkpoint_ns=... 查询最新状态
148
+ req_sub_get = Request(
149
+ {
150
+ "type": "http",
151
+ "method": "GET",
152
+ "app": app,
153
+ "path_params": {},
154
+ "query_string": f"checkpoint_ns={subagent_ns}".encode(),
155
+ }
156
+ )
157
+ sub_state_get = json.loads((await core_api.threads_state(req_sub_get)).body)
158
+ assert sub_state_get["checkpoint"]["checkpoint_ns"] == subagent_ns
159
+ assert sub_state_get["values"] == sub_state_post["values"]
160
+
161
+ # 7. 场景 4:指定特定 checkpoint_id 获取子图历史某一步状态
162
+ req_sub_step1 = Request(
163
+ {
164
+ "type": "http",
165
+ "method": "POST",
166
+ "app": app,
167
+ "path_params": {},
168
+ "query_string": b"",
169
+ }
170
+ )
171
+ req_sub_step1._json = {
172
+ "checkpoint": {
173
+ "checkpoint_ns": subagent_ns,
174
+ "checkpoint_id": cp_id_1,
175
+ }
176
+ }
177
+ sub_step1_state = json.loads((await core_api.threads_state(req_sub_step1)).body)
178
+ assert sub_step1_state["checkpoint"]["checkpoint_id"] == cp_id_1
179
+ assert len(sub_step1_state["values"]["messages"]) == 2
180
+
181
+ # 8. 场景 5:通过 POST /history 指定子图 checkpoint_ns 查询历史列表
182
+ req_hist_post = Request(
183
+ {
184
+ "type": "http",
185
+ "method": "POST",
186
+ "app": app,
187
+ "path_params": {},
188
+ "query_string": b"",
189
+ }
190
+ )
191
+ req_hist_post._json = {"checkpoint": {"checkpoint_ns": subagent_ns}, "limit": 10}
192
+ sub_hist = json.loads((await core_api.threads_history(req_hist_post)).body)
193
+ assert len(sub_hist) == 2
194
+ # 历史列表按倒序排列(最新在前)
195
+ assert sub_hist[0]["checkpoint"]["checkpoint_id"] == cp_id_2
196
+ assert sub_hist[1]["checkpoint"]["checkpoint_id"] == cp_id_1
197
+ for item in sub_hist:
198
+ assert item["checkpoint"]["checkpoint_ns"] == subagent_ns
199
+
200
+ # 9. 场景 6:查询不存在的子图命名空间,返回空 values,不被根图数据污染
201
+ req_nonexistent = Request(
202
+ {
203
+ "type": "http",
204
+ "method": "GET",
205
+ "app": app,
206
+ "path_params": {},
207
+ "query_string": b"checkpoint_ns=tools:nonexistent_call",
208
+ }
209
+ )
210
+ nonexistent_state = json.loads((await core_api.threads_state(req_nonexistent)).body)
211
+ assert nonexistent_state["checkpoint"]["checkpoint_ns"] == "tools:nonexistent_call"
212
+ assert nonexistent_state["values"] == {}
213
+ assert nonexistent_state["interrupts"] == []