Files
AgentLab/backend/tests/test_layout.py
ThearchyHelios 8905fe5e60 多 agent 节点在画布上展开协作矩阵,运行时的动效统一成一套语汇
多 agent 节点原本是个黑盒:卡片转三十秒圈,然后吐一段文本。"它同时派了三个
人"、"谁还在跑"、"并行到底省了多少"全都要点开右栏才知道,而右栏是线性的
时间线,看不出并发的形状。

现在它跑起来会在卡片里展开一块协作矩阵。三条设计上的选择:

- 花名册固定在原地,不按派单顺序重排——几轮下来眼睛知道谁在哪一行
- 条形长度按**这一轮里最慢的那个人**归一化。三个人并排三条长短不一的条,
  墙钟为什么等于最长那条、而不是三条之和,不用解释就看懂了;页脚给出
  并行一共省下多少
- 本轮没被派到的人不显示成"待命",而是留着上一轮的成绩(压暗)——它已经
  交回来了,说它待命是错的,看的人会以为它还没干活

矩阵和右栏泳道读的是同一份翻译:把 decodeRun 里那段攒泳道的逻辑抽成
reduceTeam(不可变,每次返回新对象,画布那份挂在 zustand 上需要新引用)。
两边各写一遍的话,同一次运行在右栏和画布上会给出不同的并行度——而"几个人
同时在跑、省了多少"正是这个节点唯一值得看的东西。抽完之后 check-decode
一条没红。

其余节点按同一套语汇动:边框上转一圈光弧 + 扫描线 = 正在跑,绿色扫一遍 =
刚跑完(只扫一次——常驻动画会让整张画布一直在动),短促抖动 = 刚失败,
琥珀呼吸 = 在等人。循环卡片显示"第 1/5 轮";分支跑完点亮命中的出口、压暗
其余,实际走过的边留在画布上,那就是这次的执行路径。连线上的虚线换成顺着
路径跑的光点:虚线只让人觉得"在动",光点能看出往哪走,速度按路径长度归一化
(长边上的光点走得慢会像卡住了)。

原来那个往外扩的声纳圈(pulse-ring)删掉了。同一个意思说两遍只会更吵,而且
声纳扩出去会和邻居卡片的边框糊在一起。

一个前提先验证过:offset-path 在 SVG 子元素上能不能对齐路径。测下来和
getPointAtLength 的位置差 0px,才敢用它。

排版要按**展开后**的高度给多 agent 节点留位置(layout.py 的 _height 按名册
人数算)。不留的话它一跑起来就盖住下面那张卡,而且运行结束后矩阵不会收起来
(那正是要回看的东西),重叠是永久的。

前庭敏感的人由 prefers-reduced-motion 兜住:光弧、扫描线、流光条、光点整体
不绘制,而不是把时长压到 0.01ms——那样它们会停在动画的最后一帧,光弧卡在
角落、光点全堆在起点,看着像渲染坏了。静态下"谁在跑"由实心圆点和"进行中"
三个字承担。

新增 scripts/check-canvas-fx.mjs:把脚本化的事件灌进 store(走的是页面上那一份
applyEvent / NodeCard / FlowEdge,只有事件来源是脚本),断言协作矩阵真的长出来、
并行度算对、条形长度和耗时成正比、分支命中的出口被点亮、关掉动效时光效确实不
绘制。真跑一张多 agent 的图要连模型、要等十几秒,而且撞不上"三个人同时还在跑"
那一瞬间——那样的检查会变成偶发失败。另外用仓库里 fixtures.json 那份真实导出
的事件验过一遍,含老运行那种只有中文消息串的 log(走正则兜底)。

顺手修掉检查脚本互相下绊子:check-canvas-fx 原来会留下一张没坐标的工作流
(节点全叠在原点),而 check-ui 打开 /studio 会落到"最近更新"的那张上,于是
点节点被挡住。现在落库前先让后端排好版。

292 passed(新增 2 条:展开的多 agent 卡片必须在排版里留出位置)。
2026-09-25 12:47:07 +08:00

226 lines
8.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""自动排版。
以前只有"按最长路径分层、同层按 id 字典序竖排"两条规则:并行的分支谁上谁下
是随机的(`out_no` 排在 `out_yes` 上面,因为 "n" < "y"),层间距写死 300,
两个节点扇出到同一个目标时连线还会叠在一起。这些测试守的是重写之后的四条
不变量:并行同层、分支按语义顺序、同列不重叠、走廊按车道需求留宽。
"""
from __future__ import annotations
from app.engine.layout import NODE_H, NODE_W, auto_layout
from app.engine.schema import GraphSpec
def _graph(nodes, edges=()):
return GraphSpec.model_validate({
"nodes": [
{"id": n["id"], "type": n["type"], "position": {"x": 0, "y": 0},
"data": {"label": n.get("label", ""), "config": n.get("config", {})}}
for n in nodes
],
"edges": [
{"source": e[0], "target": e[1], **({"sourceHandle": e[2]} if len(e) > 2 else {})}
for e in edges
],
})
def _parallel_graph():
"""一个入口扇出三条并行的 agent,再汇进同一个节点。"""
return _graph(
[
{"id": "in", "type": "input"},
{"id": "a1", "type": "agent"},
{"id": "a2", "type": "agent"},
{"id": "a3", "type": "agent"},
{"id": "join", "type": "llm"},
],
[("in", "a1"), ("in", "a2"), ("in", "a3"), ("a1", "join"), ("a2", "join"), ("a3", "join")],
)
def test_parallel_branches_share_a_column() -> None:
"""并行的三条支路必须落在同一列:它们之间没有依赖,谁也不该排到谁后面。"""
spec = auto_layout(_parallel_graph())
xs = {n.id: n.position.x for n in spec.nodes}
assert xs["a1"] == xs["a2"] == xs["a3"]
assert xs["in"] < xs["a1"] < xs["join"]
def test_parallel_branches_are_stacked_without_overlap() -> None:
spec = auto_layout(_parallel_graph())
ys = sorted(n.position.y for n in spec.nodes if n.id.startswith("a"))
assert len(set(ys)) == 3
# 同列相邻节点的纵向间距要够一个节点高度,否则卡片会叠在一起
assert ys[1] - ys[0] >= 60
assert ys[2] - ys[1] >= 60
def test_branch_cases_keep_their_declared_order() -> None:
"""分支的 yes/no 是语义顺序,不能按 id 字典序排("no" < "yes" 会反过来)。"""
spec = _graph(
[
{"id": "gate", "type": "branch",
"config": {"cases": [{"key": "yes"}, {"key": "no"}]}},
{"id": "out_no", "type": "output"},
{"id": "out_yes", "type": "output"},
],
[("gate", "out_yes", "yes"), ("gate", "out_no", "no")],
)
laid = auto_layout(spec)
pos = {n.id: n.position for n in laid.nodes}
assert pos["out_yes"].y < pos["out_no"].y
assert pos["out_yes"].x == pos["out_no"].x
def test_loop_body_stays_right_of_the_loop_node() -> None:
"""回边不能被当成普通边参与分层——那样循环体会跑到循环节点左边去。"""
spec = _graph(
[
{"id": "start", "type": "input"},
{"id": "each", "type": "loop"},
{"id": "body", "type": "llm"},
{"id": "collect", "type": "transform"},
{"id": "done", "type": "output"},
],
[
("start", "each"),
("each", "body", "body"),
("body", "collect"),
("collect", "each"),
("each", "done", "done"),
],
)
laid = auto_layout(spec)
pos = {n.id: n.position for n in laid.nodes}
assert pos["start"].x < pos["each"].x < pos["body"].x < pos["collect"].x
# 循环体出口排在结束出口上面
assert pos["body"].y < pos["done"].y
def test_corridor_grows_with_lane_demand() -> None:
"""扇出的线越多,走廊越宽——宽度不够,前端的车道就铺不开,线会叠回去。"""
narrow = auto_layout(_parallel_graph())
wide = auto_layout(_graph(
[{"id": "in", "type": "input"}, {"id": "join", "type": "llm"}]
+ [{"id": f"m{i}", "type": "llm"} for i in range(8)],
[("in", f"m{i}") for i in range(8)] + [(f"m{i}", "join") for i in range(8)],
))
def corridor(spec) -> float:
xs = sorted({n.position.x for n in spec.nodes})
return min(b - a for a, b in zip(xs, xs[1:])) - NODE_W
assert corridor(wide) > corridor(narrow)
def test_long_edges_do_not_widen_a_single_corridor_forever() -> None:
"""走廊有上限。20 路扇出也不该把图撑到屏幕外面去。"""
spec = auto_layout(_graph(
[{"id": "in", "type": "input"}]
+ [{"id": f"m{i}", "type": "llm"} for i in range(20)],
[("in", f"m{i}") for i in range(20)],
))
xs = sorted({n.position.x for n in spec.nodes})
assert xs[1] - xs[0] <= NODE_W + 460
def test_same_column_never_overlaps() -> None:
"""一整列节点两两之间必须留出至少一个卡片的高度。"""
spec = auto_layout(_graph(
[{"id": "in", "type": "input"}] + [{"id": f"n{i}", "type": "llm"} for i in range(12)],
[("in", f"n{i}") for i in range(12)],
))
ys = sorted(n.position.y for n in spec.nodes if n.id != "in")
gaps = [b - a for a, b in zip(ys, ys[1:])]
assert gaps and min(gaps) >= 60
def test_layout_is_stable_and_idempotent() -> None:
"""同一张图排两次必须得到同一份坐标,否则保存后一刷新就跳。"""
first = auto_layout(_parallel_graph()).model_dump(mode="json")
second = auto_layout(GraphSpec.model_validate(first)).model_dump(mode="json")
assert [(n["id"], n["position"]) for n in first["nodes"]] == \
[(n["id"], n["position"]) for n in second["nodes"]]
def test_layout_does_not_touch_the_graph_itself() -> None:
"""排版只改坐标:节点、边、配置一个都不能丢,也不能多出来。"""
before = _parallel_graph()
ids = [(n.id, str(n.type)) for n in before.nodes]
edges = [(e.source, e.target, e.sourceHandle or "") for e in before.edges]
after = auto_layout(before)
assert [(n.id, str(n.type)) for n in after.nodes] == ids
assert [(e.source, e.target, e.sourceHandle or "") for e in after.edges] == edges
def test_cycle_does_not_hang_or_collapse() -> None:
"""没有任何入口的纯环图也得排完,不能死循环。"""
spec = _graph(
[{"id": "x", "type": "llm"}, {"id": "y", "type": "llm"}],
[("x", "y"), ("y", "x")],
)
laid = auto_layout(spec)
assert {n.id for n in laid.nodes} == {"x", "y"}
def test_dangling_edges_and_empty_graph_are_survivable() -> None:
assert auto_layout(GraphSpec()).nodes == []
spec = auto_layout(_graph(
[{"id": "a", "type": "llm"}],
[("a", "ghost"), ("ghost", "a")],
))
assert spec.nodes[0].position.x >= 0
def test_self_loop_is_kept() -> None:
spec = auto_layout(_graph([{"id": "a", "type": "llm"}], [("a", "a")]))
assert spec.nodes[0].position.x >= 0
assert len(spec.edges) == 1
#: 前端 TeamMatrix 展开后的卡片高度:标题 34 + 摘要 28 + 表头 19 + n 行 21
#: + 页脚 25 + 内外边距 14 + 在跑那人的任务行 26
def _expanded_height(agents: int) -> float:
return 146 + 21 * agents + 26
def _two_rows(agents: int) -> tuple[float, float]:
"""同一列里放一个多 agent 节点和一个普通节点,返回两者的中心 y。"""
spec = auto_layout(_graph(
[
{"id": "team", "type": "supervisor",
"config": {"agents": [{"name": f"a{i}"} for i in range(agents)]}},
{"id": "peer", "type": "llm"},
],
[],
))
return tuple(sorted(n.position.y for n in spec.nodes)) # type: ignore[return-value]
def test_supervisor_reserves_room_for_its_expanded_matrix() -> None:
"""多 agent 节点跑起来会在卡片里展开协作矩阵,排版要按展开后的高度留位置。
不然它一跑起来就盖住下面那张卡;而且运行结束后矩阵不会收起来(那正是要
回看的东西),重叠是永久的。
"""
for agents in (2, 4, 6):
top, bottom = _two_rows(agents)
# 展开后的那张卡占的区间,必须和下面那张卡留出间距
gap = (bottom - NODE_H / 2) - (top + _expanded_height(agents) / 2)
assert gap >= 50, f"{agents} 人矩阵会压到下面那张卡,只剩 {gap:.0f}px"
# 名册越大留得越多;没有成员就不该白占地方
assert _two_rows(6)[1] - _two_rows(6)[0] > _two_rows(2)[1] - _two_rows(2)[0]
def test_plain_nodes_keep_the_compact_row_gap() -> None:
"""没有展开行为的节点不该跟着变高,否则整张图会白白被撑开。"""
ys = sorted(n.position.y for n in auto_layout(_graph(
[{"id": "a", "type": "llm"}, {"id": "b", "type": "llm"}, {"id": "c", "type": "llm"}],
[],
)).nodes)
assert ys[1] - ys[0] <= 140, f"普通节点之间隔了 {ys[1] - ys[0]:.0f}px"