mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:21:57 +00:00
f0fd63f0b9
用户的原话是「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 个测试全过。
145 lines
4.7 KiB
Python
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}"
|