Files
AgentLab/backend/tests/test_bus_attach.py
T
ThearchyHelios f0fd63f0b9 UI 重构:Copilot 进侧栏,三个页面共用一条助手流
用户的原话是「copilot(包括问答)可以是侧边栏的效果」「显示他的简要思考节点,
使得用户知道他大致在想什么,以及用了哪些工具」。

画布右栏从「运行 / 属性 → 时间线 / 成果 / 事件」(两层嵌套 5 个视图)变成
「助手 / 属性」一层。助手栏里是一条流:你让它做什么、它做了什么、结果是什么。
Copilot 建图和跑图在协议上完全无关(SSE 操作流 vs 事件 WebSocket,不共享
seq 和 node_id),但对用户是一件事的两半,decode.ts 把两种流译成同一种 Step
之后才能排进同一条时间轴。

Copilot 不再是弹窗。弹窗的问题不是样式:每改一次图都要重开一次,关掉之后
需求文本就没了,想微调只能重打;而画布左下角那条浮条(z-30)和 Toast
(z-100)、Modal(z-50)三层各自为政,本身也是重复的一份 Copilot 状态渲染。
两个都删了,输入框就在栏里,说完一句接着说下一句。右栏没删——它是发起运行
和处理审批的唯一入口,RunLauncher 原样保留,只是跑起来会自动收起给执行过程
让位。tab 用 hidden 切换而不是卸载,不然点一下「属性」看个节点再切回来,
输入框草稿和展开状态全丢。

问数据页和运行页换成同一个 AssistantStream + 同一个 decode.ts。RunPanel 里
那套平行的 EVENT_META + describe() 删了:留着两套翻译,同一次运行在不同页面
会读出不同的故事,而用户没法判断哪个是真的。同理合并了两个耗时格式化函数
(画布节点显示"0ms",右边同一步什么都不显示,两块还并排放着)。运行页列表
加了内容摘要——原本每行只有"临时图 · 1.2s · 340 tok",十条长得一模一样,
要分清哪条是哪条只能挨个点开。

跑真实运行时发现两个缺陷:

1. 后端丢事件(app/core/bus.py)。WebSocket 先读历史、再订阅,两步之间图
   正好跑出来的事件既不在历史里也不在实时流里,永远丢了。而 subscribe() 是
   异步生成器,真正注册还要等到第一次迭代,窗口比看上去更大。实测一次跑图
   开头的 node.finished/node.started 消失,界面上表现为某个节点永远在转圈、
   它的子步骤跑到了顶层。改成先 attach 占位、再读历史,重叠部分按 seq 去重。
   tests/test_bus_attach.py 里有一条刻画旧行为的测试,说明这个窗口确实存在。

2. 循环里的多轮审批被算错。我在 S1 按内容去重,但三轮审批的 title 一模一样,
   和 LangGraph 的重放无法区分——结果第 2、3 次真实审批被当成重放吞掉,而
   三次"你驳回了/你放行了"各自成行。改成开/闭配对:开着就是同一次,闭了才是
   新一次;决定折进那条审批行而不是另起一行。真实运行(驳回 → 改写 → 再驳回
   → 改写 → 放行)已收进 fixtures。

验证:三层各守一件事,共 76 条断言全过。check-decode 守翻译(含新的多轮审批
和截断结果集),check-stream 守渲染,新增 check-ui 守"接起来还对不对"——真
浏览器真后端,包括「切走再切回来草稿还在」「唯一的运行入口还在」「原始事件
切得到也切得回」。另外真跑了一次带三轮审批的图和两次 Copilot 生成(60s,
deepseek-v4-pro),截图逐帧看过。后端 78 个测试全过。
2026-09-17 18:29:16 +08:00

145 lines
4.7 KiB
Python

"""事件总线的订阅时序。
这里守的是一个只在**并发**下才出现的缺陷:先读历史、再订阅,两步之间发出的
事件既不在历史里、也不在实时流里,永远丢了。串行地测"发一条收一条"永远
测不出来——必须在"读历史"的那一刻往总线里灌事件。
真实表现:一次跑图开头两三条 node.started/node.finished 消失,界面上某个节点
永远在转圈,它的子步骤跑到了顶层。
"""
from __future__ import annotations
import asyncio
import pytest
from app.core.bus import EventBus
from app.core.events import RunEventModel
def _event(run_id: str, seq: int, type_: str = "node.started") -> RunEventModel:
return RunEventModel(run_id=run_id, seq=seq, type=type_, node_id=None, data={})
@pytest.mark.asyncio
async def test_attach_before_history_loses_nothing() -> None:
bus = EventBus()
run_id = "r1"
got: list[int] = []
with bus.attach(run_id) as sub:
# 占位之后、开始消费之前发出的事件要留在队列里等着,不能丢
await bus.publish(_event(run_id, 1))
await bus.publish(_event(run_id, 2))
async def drain() -> None:
async for event in sub.stream():
got.append(event.seq)
task = asyncio.create_task(drain())
await asyncio.sleep(0)
await bus.publish(_event(run_id, 3))
await bus.close(run_id)
await asyncio.wait_for(task, timeout=2)
assert got == [1, 2, 3]
@pytest.mark.asyncio
async def test_after_filter_dedups_overlap() -> None:
"""占位到读完历史之间的事件两边都有一份,按 seq 滤掉重复的那份。"""
bus = EventBus()
run_id = "r2"
got: list[int] = []
with bus.attach(run_id) as sub:
for seq in (1, 2, 3):
await bus.publish(_event(run_id, seq))
# 假设补历史时读到了 seq<=2,那实时流里只该再吐 3
async def drain() -> None:
async for event in sub.stream(after=2):
got.append(event.seq)
task = asyncio.create_task(drain())
await asyncio.sleep(0)
await bus.close(run_id)
await asyncio.wait_for(task, timeout=2)
assert got == [3]
@pytest.mark.asyncio
async def test_detach_removes_subscriber() -> None:
bus = EventBus()
run_id = "r3"
sub = bus.attach(run_id)
assert bus.subscriber_count(run_id) == 1
sub.close()
assert bus.subscriber_count(run_id) == 0
@pytest.mark.asyncio
async def test_subscribe_registers_late() -> None:
"""刻画 subscribe() 的行为,说明 attach 为什么必须存在。
subscribe() 是异步生成器:拿到它并不等于订阅上了,要到第一次迭代才真正
注册。调用方"先拿生成器、再读历史、最后开始迭代"是最自然的写法,而那
恰好让整个读历史的窗口都处于无人接收的状态。
"""
bus = EventBus()
run_id = "r5"
gen = bus.subscribe(run_id)
assert bus.subscriber_count(run_id) == 0, "拿到生成器还不算订阅"
# 这条发出去时没人收——旧代码丢事件就是丢在这个窗口里
await bus.publish(_event(run_id, 1))
got: list[int] = []
async def drain() -> None:
async for event in gen:
got.append(event.seq)
task = asyncio.create_task(drain())
await asyncio.sleep(0.01)
assert bus.subscriber_count(run_id) == 1, "迭代之后才注册上"
await bus.publish(_event(run_id, 2))
await bus.close(run_id)
await asyncio.wait_for(task, timeout=2)
assert got == [2], "seq=1 确实丢了;attach 就是为了消掉这个窗口"
@pytest.mark.asyncio
async def test_concurrent_publish_during_history_read() -> None:
"""把真实时序演一遍:占位 → 慢慢读历史(期间图还在跑)→ 开始消费。"""
bus = EventBus()
run_id = "r4"
history: list[int] = []
live: list[int] = []
with bus.attach(run_id) as sub:
async def keep_running() -> None:
# 图不会等前端读完历史
for seq in range(1, 6):
await bus.publish(_event(run_id, seq))
await asyncio.sleep(0.005)
await bus.close(run_id)
runner = asyncio.create_task(keep_running())
# "读历史"——只读到前两条,剩下的还没落库
await asyncio.sleep(0.012)
history.extend([1, 2])
async def drain() -> None:
async for event in sub.stream(after=max(history)):
live.append(event.seq)
await asyncio.wait_for(asyncio.gather(runner, drain()), timeout=3)
# 3/4/5 一条都不能少——这正是修复前丢掉的那些
assert live == [3, 4, 5], f"历史={history} 实时={live}"