Files
AgentLab/backend/tests/test_copilot_stream.py
T
ThearchyHelios 1756476a30 Copilot 等待期不再是黑箱,外加项目第一组测试
从点"生成"到第一个节点落到画布上,本机实测 31.7 秒(DeepSeek V4 pro,中等
复杂度需求)。这段时间界面上只有"正在起草…"五个字一动不动——用户没法判断
是在想还是已经卡死。生成质量本身没问题,瓶颈全在这段等待的体验。

三处改动:

## 思考转发

_iter_ops 原先只取正文、丢掉其余一切,模型的 thinking 块也一并丢了。现在用
thinking_text() 抽出来单独转成 thinking 事件,前端可折叠地实时显示。

刻意不用"把非 JSON 行当成思考"这种取巧写法:那会把模型的 markdown 注释、
协议跑偏时的乱输出一并当作思考展示出来,反而误导。不支持 thinking 的模型
就只有心跳,不硬凑假的思考。

注意这条只对支持 adaptive thinking 的模型(Claude 4.6+)生效,当前默认的
DeepSeek V4 pro 不会产出——代码路径用构造的 chunk 验证过,但端到端触发需要
启用 Anthropic provider。

## 心跳与阶段

模型沉默时每 3 秒补一拍,带阶段和已用时长。必须在 _with_heartbeat 这一层做:
模型不吐 chunk 时 _iter_ops 里那个 async for 的循环体根本不执行,在里面判断
时间差轮不到。阶段跟着实际操作走(planning → building → wiring → finalizing),
阶段会变本身就是"它还活着"的信号。实测那 31.7 秒里补了 10 拍。

## 失败后不用重敲需求

生成失败只有一个 ✕,重试得重新打开 Modal 把需求再敲一遍——而失败多半跟需求
本身无关(模型抽风、协议跑偏、网断了)。store 里留住 lastInstruction,
错误浮条上直接给"用同一需求重试"。

## 顺带:项目第一组测试

这组守的是体验契约不是实现细节——thinking 和 heartbeat 两路反馈只要断了就是
回归,而这种回归在界面上表现为"又变回干等",很难被注意到。4 个用例覆盖:
thinking 块转发、纯文本模型不伪造思考、沉默期补心跳、阶段随进度推进。

pytest 走 [dev] extra,environment.yml 一并带上。
2026-09-14 16:01:06 +08:00

116 lines
4.3 KiB
Python

"""Copilot 流式生成:等待期间必须有反馈。
这组测试守的是一个体验契约,不是实现细节:从请求发出到第一个节点落到画布上,
本机实测有 31.7 秒(DeepSeek V4 pro,中等复杂度的需求)。这段时间里前端如果
一个事件都收不到,用户没法判断是在想还是已经卡死——所以 thinking 和 heartbeat
这两路反馈只要断了,就是回归。
"""
from __future__ import annotations
import asyncio
import pytest
from app.api import copilot as cp
from app.api.copilot import _iter_ops, _with_heartbeat
class Chunk:
"""模拟流式 chunk。content 是块列表时对应 Claude 4.6+ 的 thinking 混排。"""
def __init__(self, content):
self.content = content
class FakeModel:
def __init__(self, chunks, delay: float = 0.0):
self._chunks = chunks
self._delay = delay
async def astream(self, messages):
for chunk in self._chunks:
if self._delay:
await asyncio.sleep(self._delay)
yield chunk
async def _collect(gen):
return [op async for op in gen]
@pytest.mark.asyncio
async def test_thinking_blocks_become_events():
"""支持 thinking 的模型,思考要原样转给前端。"""
model = FakeModel([
Chunk([{"type": "thinking", "thinking": "用户要一个调研流程。"}]),
Chunk([{"type": "thinking", "thinking": "先搜索,再抓取。"}]),
Chunk([{"type": "text", "text": '{"op":"plan","summary":"三步"}\n'}]),
Chunk([{"type": "text", "text": '{"op":"done","explanation":"完成"}\n'}]),
])
ops = await _collect(_iter_ops(model, []))
kinds = [o["op"] for o in ops]
assert kinds.count("thinking") == 2
assert ops[0]["delta"] == "用户要一个调研流程。"
assert kinds[-1] == "done", "done 之后就该停,后面的闲话不要"
@pytest.mark.asyncio
async def test_plain_text_is_not_passed_off_as_thinking():
"""不支持 thinking 的模型不该被凭空造出思考。
有一种取巧写法是"把非 JSON 行当成思考",那会把模型的 markdown 注释、
协议跑偏时的乱输出一并当作思考展示——宁可只有心跳,也不要假的思考。
"""
model = FakeModel([
Chunk("模型的随口注释,不是 JSON\n"),
Chunk('{"op":"plan","summary":"x"}\n'),
Chunk('{"op":"done","explanation":"y"}\n'),
])
kinds = [o["op"] for o in await _collect(_iter_ops(model, []))]
assert "thinking" not in kinds
assert kinds == ["plan", "done"]
@pytest.mark.asyncio
async def test_heartbeat_fires_while_model_is_silent(monkeypatch):
"""模型长时间不吐字时要补心跳,否则前端一片死寂。
心跳必须在 _with_heartbeat 这一层做:模型不产出 chunk 时,_iter_ops 里那个
async for 的循环体根本不执行,在里面判断时间差是轮不到的。
"""
monkeypatch.setattr(cp, "_HEARTBEAT_SECONDS", 0.2)
class SlowModel:
async def astream(self, messages):
await asyncio.sleep(0.9) # 模拟"正在想"
yield Chunk('{"op":"plan","summary":"终于"}\n')
yield Chunk('{"op":"done","explanation":"z"}\n')
ops = await _collect(_with_heartbeat(_iter_ops(SlowModel(), [])))
beats = [o for o in ops if o["op"] == "heartbeat"]
assert len(beats) >= 2, "0.9 秒的沉默至少该有两拍"
assert beats[0]["phase"] == "planning"
assert beats[0]["elapsed_ms"] > 0
assert [o["op"] for o in ops][-1] == "done"
@pytest.mark.asyncio
async def test_heartbeat_phase_follows_progress(monkeypatch):
"""阶段要跟着实际操作走——阶段会变本身就是"它还活着"的信号。"""
monkeypatch.setattr(cp, "_HEARTBEAT_SECONDS", 0.2)
class Staged:
async def astream(self, messages):
yield Chunk('{"op":"add_node","node":{"id":"a","type":"input"}}\n')
await asyncio.sleep(0.5) # 放完节点后卡一会儿
yield Chunk('{"op":"done","explanation":"d"}\n')
ops = await _collect(_with_heartbeat(_iter_ops(Staged(), [])))
beats = [o for o in ops if o["op"] == "heartbeat"]
assert beats, "add_node 之后的停顿也该有心跳"
assert beats[0]["phase"] == "building", "放过节点了,阶段不该还停在 planning"