Files
2026Technology-Competition/docs/superpowers/plans/2026-08-29-iteration-websocket-progress.md
T
lhl 80daadcd31 fix(websocket): 修复 app.py 不可导入并新增真实链路验证
- 将 /api/sessions/{sid}/ws 端点移入 create_app(此前置于模块级导致整模块 import NameError,回归被验证拦截)
- register_loop + subscribe 调整至 accept 之前,缩小连接已开但未订阅期间的进度丢失窗口
- 新增 tests/test_verify_ws_real_flow.py:驱动真实 HTTP 聊天流程断言 WS 收到 agent 实际发射的 parse/impact 进度
- 同步 WebSocket 计划文档 Task 3 代码片段(标注端点必须位于 create_app 内)
- 全量 pytest 实测 583 passed / 99.03% 达标
2026-08-29 14:36:12 +08:00

21 KiB
Raw Blame History

WebSocket 实时进度流 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在生成/影响调查等耗时流程执行期间,通过 WebSocket 把 progress/error 事件实时推送到前端,用户无需重载会话即可看到进度;保留既有「持久化 + 重载渲染」作为离线兜底。

Architecture: 新增进程内 ProgressHubasyncio 发布/订阅,单例)。ChatAgent 在产出每个 progress/error 条目时同步调用 hub.emit(sid, event)app.py 暴露 /api/sessions/{sid}/ws WebSocket 端点,订阅 hub 并把事件转发给对应会话的连接;前端 chat_ws.js 打开 WS 并按事件渲染进度条/错误行。持久化(role='progress'/'error')保持不变,重载场景仍可见历史。

Tech Stack: FastAPI/Starlette WebSocketwebsockets 包(uvicorn 生产运行所需,测试用 Starlette TestClient.websocket_connect 无需额外服务依赖);前端原生 WebSocket API;纯函数模块 + Node node:test 单测。

Global Constraints

  • 中文优先:所有 UI 文案、注释、日志使用中文(专有名词/代码关键字除外)。
  • 不新增重依赖:仅新增 websocketspyproject dependencies);向量库等不在本迭代。
  • TDD:每个任务先写失败测试,再实现;测试全绿后方可 commit。
  • 覆盖率门禁:fail_under=99(pyproject 现有配置),新增 Python 代码须有测试覆盖。
  • 向后兼容:未打开 WS 时,旧「HTTP 响应 progress + 重载渲染」路径不受影响;WS 关闭/不可用时前端自动回退到现有轮询/重载逻辑。
  • 单进程假设:hub 为进程内单例,多 worker 部署下跨进程不互通(在 design.md 标注限制,本迭代不做跨进程总线)。

Task 1: ProgressHub 发布/订阅核心

Files:

  • Create: src/genesis/server/hub.py
  • Test: tests/test_progress_hub.py

Interfaces:

  • Consumes: 无

  • Produces:

    • genesis.server.hub.ProgressHub 类,方法 register_loop(loop), subscribe(sid) -> asyncio.Queue, unsubscribe(sid, queue), emit(sid, event)
    • 模块级单例 genesis.server.hub.hub(供 app 与测试共享)
  • Step 1: 写失败测试

# tests/test_progress_hub.py
import asyncio
import pytest
from genesis.server.hub import ProgressHub


@pytest.mark.asyncio
async def test_subscribe_receives_emitted_event():
    h = ProgressHub()
    h.register_loop(asyncio.get_running_loop())
    q = h.subscribe("s1")
    h.emit("s1", {"type": "progress", "step": "parse", "status": "ok"})
    event = await asyncio.wait_for(q.get(), 1.0)
    assert event["step"] == "parse"


@pytest.mark.asyncio
async def test_unsubscribe_stops_delivery():
    h = ProgressHub()
    h.register_loop(asyncio.get_running_loop())
    q = h.subscribe("s1")
    h.unsubscribe("s1", q)
    h.emit("s1", {"type": "progress"})
    with pytest.raises(asyncio.TimeoutError):
        await asyncio.wait_for(q.get(), 0.2)


@pytest.mark.asyncio
async def test_multiple_subscribers_all_receive():
    h = ProgressHub()
    h.register_loop(asyncio.get_running_loop())
    q1, q2 = h.subscribe("s1"), h.subscribe("s1")
    h.emit("s1", {"type": "progress", "step": "gen"})
    a = await asyncio.wait_for(q1.get(), 1.0)
    b = await asyncio.wait_for(q2.get(), 1.0)
    assert a["step"] == b["step"] == "gen"
  • Step 2: 运行测试确认失败

Run: python -m pytest tests/test_progress_hub.py -q Expected: FAILModuleNotFoundError: genesis.server.hub

  • Step 3: 实现最小版本
# src/genesis/server/hub.py
from __future__ import annotations

import asyncio
from typing import Any, Dict, List

_event = Dict[str, Any]


class ProgressHub:
    """进程内会话级进度发布/订阅(单例)。

    - subscribe(sid) 返回专属 asyncio.Queueemit(sid, event) 向该 sid 全部队列投递。
    - emit 从同步线程(FastAPI 线程池中的 sync 端点)调用,经由已注册事件循环
      run_coroutine_threadsafe 安全投递;未注册 loop 时降级为直接放入队列(同线程场景)。
    """

    def __init__(self) -> None:
        self._loop: asyncio.AbstractEventLoop | None = None
        self._subs: Dict[str, List[asyncio.Queue]] = {}

    def register_loop(self, loop: asyncio.AbstractEventLoop) -> None:
        self._loop = loop

    def subscribe(self, sid: str) -> asyncio.Queue:
        q: asyncio.Queue = asyncio.Queue()
        self._subs.setdefault(sid, []).append(q)
        return q

    def unsubscribe(self, sid: str, q: asyncio.Queue) -> None:
        qs = self._subs.get(sid)
        if qs and q in qs:
            qs.remove(q)
            if not qs:
                self._subs.pop(sid, None)

    def emit(self, sid: str, event: _event) -> None:
        for q in list(self._subs.get(sid, [])):
            if self._loop is not None:
                asyncio.run_coroutine_threadsafe(q.put(event), self._loop)
            else:
                q.put_nowait(event)


hub = ProgressHub()
  • Step 4: 运行测试确认通过

Run: python -m pytest tests/test_progress_hub.py -q Expected: PASS3 passed

  • Step 5: 提交
git add src/genesis/server/hub.py tests/test_progress_hub.py
git commit -m "feat(server): 新增 ProgressHub 进程内进度发布/订阅单例"

Task 2: ChatAgent 发射进度/错误事件

Files:

  • Modify: src/genesis/chat/agent.py
  • Test: tests/test_chat_agent_ws.py

Interfaces:

  • Consumes: genesis.server.hub.hub(模块单例)

  • Produces: ChatAgent.__init__ 新增可选参数 progress_sink: Callable[[dict], None] | None_emit_progress(item) / _emit_error(reply, action) 方法,向 sink(或 hub)发射事件,且保持 _persist_progress / _store_error 原有持久化不变。

  • Step 1: 写失败测试

# tests/test_chat_agent_ws.py
from genesis.chat.agent import ChatAgent
from genesis.server.service import GenesisService
from genesis.server.store import ProjectsStore, SessionStore


def _make_agent(sink):
    store = SessionStore(db_path=":memory:")
    projects = ProjectsStore(db_path=":memory:")
    svc = GenesisService(store=store, data_root="data", engine="fake", projects=projects)
    return ChatAgent(service=svc, fake=True, engine="fake", progress_sink=sink)


def test_agent_emits_progress_events():
    events = []
    agent = _make_agent(events.append)
    sid = agent.service.create_session("u1").session_id
    # 走上传 + 生成(fake engine),断言过程中有 progress 事件发射
    # 简化:直接调用内部 _emit_progress 验证接线
    agent._emit_progress(sid, {"step": "parse", "status": "ok", "detail": "解析完成"})
    assert events and events[0]["type"] == "progress"
    assert events[0]["step"] == "parse"


def test_agent_emits_error_event():
    events = []
    agent = _make_agent(events.append)
    sid = agent.service.create_session("u1").session_id
    agent._emit_error(sid, "解析失败:boom", "generate")
    errs = [e for e in events if e["type"] == "error"]
    assert errs and "boom" in errs[0]["detail"]
  • Step 2: 运行测试确认失败

Run: python -m pytest tests/test_chat_agent_ws.py -q Expected: FAIL_emit_progress / _emit_error 不存在)

  • Step 3: 实现最小版本

agent.py 顶部 from genesis.server.hub import hub as _hub(注意避免与既有命名冲突,若已存在 hub 局部变量则改名导入为 _progress_hub)。

ChatAgent.__init__ 增加参数并在方法内保存:

def __init__(self, service, fake=False, engine=None, progress_sink=None):
    self.service = service
    self.fake = fake
    self.engine = engine
    self.progress_sink = progress_sink

新增两个方法(放在 _persist_progress / _store_error 附近):

def _emit_progress(self, session_id, item):
    event = {
        "type": "progress",
        "step": item.get("step", ""),
        "status": item.get("status", ""),
        "detail": item.get("detail", ""),
    }
    if self.progress_sink is not None:
        self.progress_sink(event)
    else:
        _progress_hub.emit(session_id, event)

def _emit_error(self, session_id, reply, action):
    event = {"type": "error", "detail": reply, "action": action}
    if self.progress_sink is not None:
        self.progress_sink(event)
    else:
        _progress_hub.emit(session_id, event)

保持既有 _persist_progress / _store_error 不变(持久化兜底仍生效)。

在四个流程方法(_auto_generate_run_parse_run_impact_run_generate_run_qa)中,凡是 progress.append(item) 之后追加 self._emit_progress(session_id, item);在 _store_error 调用处(现有 reply = f"解析失败:{e}" 等分支)改为先 self._emit_error(session_id, reply, action)_store_error(...)

  • Step 4: 运行测试确认通过

Run: python -m pytest tests/test_chat_agent_ws.py -q Expected: PASS2 passed

  • Step 5: 提交
git add src/genesis/chat/agent.py tests/test_chat_agent_ws.py
git commit -m "feat(chat): agent 在产出进度/错误时发射事件(保留持久化兜底)"

Task 3: WebSocket 端点

Files:

  • Modify: src/genesis/server/app.py
  • Test: tests/test_progress_ws.py

Interfaces:

  • Consumes: genesis.server.hub.hub(单例)、WebSocketWebSocketDisconnectfastapi

  • Produces: GET /api/sessions/{sid}/ws 端点;pyproject 新增 websockets 依赖

  • Step 1: 写失败测试

# tests/test_progress_ws.py
import threading
from fastapi.testclient import TestClient
from genesis.server.app import create_app
from genesis.server.hub import hub
from genesis.server.store import SessionStore


def _client(tmp_path):
    return TestClient(create_app(
        store=SessionStore(db_path=str(tmp_path / "s.db")),
        data_root=str(tmp_path / "data"), engine="fake"))


def test_ws_streams_progress(tmp_path):
    c = _client(tmp_path)

    def trigger():
        hub.emit("ws-s1", {"type": "progress", "step": "gen", "status": "ok", "detail": "生成中"})

    with c.websocket_connect("/api/sessions/ws-s1/ws") as ws:
        threading.Thread(target=trigger).start()
        data = ws.receive_json(timeout=2.0)
        assert data["type"] == "progress"
        assert data["step"] == "gen"
  • Step 2: 运行测试确认失败

Run: python -m pytest tests/test_progress_ws.py -q Expected: FAIL404 / 路由不存在)

  • Step 3: 实现最小版本

app.py 导入:from fastapi import WebSocket, WebSocketDisconnect,并确认文件顶部已 from genesis.server.hub import hub

create_app 内新增端点:

    # 注意:端点必须定义在 create_app 函数体内(缩进),因为 app 是局部变量;
    # 若误置于模块级,@app.websocket 引用未定义的 app 会导致整模块 import 即 NameError。
    @app.websocket("/api/sessions/{sid}/ws")
    async def session_progress_ws(ws: WebSocket, sid: str):
        # 先注册循环并订阅,再 accept,缩小「连接已开但尚未订阅」期间的进度丢失窗口
        hub.register_loop(asyncio.get_running_loop())
        q = hub.subscribe(sid)
        await ws.accept()
        try:
            while True:
                event = await q.get()
                await ws.send_json(event)
        except WebSocketDisconnect:
            pass
        finally:
            hub.unsubscribe(sid, q)

asyncio 已在 app.py 导入;若未导入则补 import asyncio。)

pyproject.tomldependencies 增加 "websockets>=12",并同步 README 安装说明。

  • Step 4: 运行测试确认通过

Run: python -m pytest tests/test_progress_ws.py -q Expected: PASS

  • Step 5: 提交
git add src/genesis/server/app.py tests/test_progress_ws.py pyproject.toml README.md
git commit -m "feat(server): 暴露 /api/sessions/{sid}/ws 进度流端点(+websockets 依赖)"

Task 4: 前端 chat_ws.js 与集成

Files:

  • Create: src/genesis/server/static/chat_ws.js
  • Modify: src/genesis/server/static/chat.html
  • Test: tests/test_chat_ws.js

Interfaces:

  • Consumes: POST/WS 端点地址约定;GenesisState 可选

  • Produces: window.GenesisWS.connectProgressWs(sid, handlers)GenesisWS.applyProgressEvent(event, render)

  • Step 1: 写失败测试

// tests/test_chat_ws.js
const test = require('node:test');
const assert = require('node:assert');
const { applyProgressEvent } = require('../src/genesis/server/static/chat_ws.js');

test('applyProgressEvent 渲染 progress 角色', () => {
  const got = [];
  applyProgressEvent({ type: 'progress', step: 'parse', detail: '完成' }, (role, text) => got.push([role, text]));
  assert.strictEqual(got.length, 1);
  assert.strictEqual(got[0][0], 'progress');
  assert.ok(got[0][1].includes('parse'));
});

test('applyProgressEvent 渲染 error 角色', () => {
  const got = [];
  applyProgressEvent({ type: 'error', detail: '炸了' }, (role, text) => got.push([role, text]));
  assert.strictEqual(got[0][0], 'error');
  assert.ok(got[0][1].includes('炸了'));
});
  • Step 2: 运行测试确认失败

Run: node --test tests/test_chat_ws.js Expected: FAIL(模块不存在)

  • Step 3: 实现最小版本
// src/genesis/server/static/chat_ws.js UMD
(function (root, factory) {
  const api = factory();
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
  else root.GenesisWS = api;
})(typeof self !== 'undefined' ? self : this, function () {
  function connectProgressWs(sid, handlers) {
    const proto = location.protocol === 'https:' ? 'wss' : 'ws';
    let ws;
    try {
      ws = new WebSocket(`${proto}://${location.host}/api/sessions/${encodeURIComponent(sid)}/ws`);
    } catch (err) {
      // 连接失败(如无 WS 依赖 / 代理拦截)静默降级;现有持久化 + 重载兜底仍可见进度
      if (handlers.onClose) handlers.onClose();
      return null;
    }
    ws.onmessage = (ev) => {
      let e;
      try { e = JSON.parse(ev.data); } catch { return; }
      if (handlers.onProgress) handlers.onProgress(e);
    };
    ws.onerror = () => { try { ws.close(); } catch {} };
    ws.onclose = () => handlers.onClose && handlers.onClose();
    return ws;
  }
  function applyProgressEvent(e, render) {
    if (e.type === 'progress') render('progress', `${e.step}: ${e.detail || ''}`);
    else if (e.type === 'error') render('error', e.detail || '错误');
  }
  return { connectProgressWs, applyProgressEvent };
});

chat.html 集成:

  • <head> 末尾增加 <script src="/chat_ws.js"></script>(置于 chat_state.js 之后)。

  • 新增 app.py 同级静态路由 /chat_ws.js(仿照 /chat_state.jsFileResponse 返回,media_type application/javascript)。

  • send() 在获得 sid 后(或 loadSession 成功时),若 sid 有效且尚无 WS,调用:

    progressWs = GenesisWS.connectProgressWs(sid, {
      onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text) => addMsg(role, text)),
      onClose: () => {},
    });
    

    其中 addMsg 复用既有消息渲染(roleprogress/error 时走灰条样式,与 loadSession 一致)。

  • 切换会话 / newSessionprogressWs && progressWs.close()

  • WebSocket 不可用或连接失败,onClose 静默;既有「重载渲染持久化进度」仍是兜底,不影响功能。

  • Step 4: 运行测试确认通过

Run: node --test tests/test_chat_ws.js Expected: PASS2 passed

  • Step 5: 提交
git add src/genesis/server/static/chat_ws.js src/genesis/server/static/chat.html src/genesis/server/app.py tests/test_chat_ws.js
git commit -m "feat(chat): 前端 chat_ws.js 实时渲染进度/错误(保留重载兜底)"

Task 5: 端到端冒烟 + 文档

Files:

  • Test: tests/test_progress_e2e.py
  • Modify: docs/design.md(§12 追加 WebSocket 进度流记录)、_AI_USAGE_LOG.md

Interfaces:

  • Consumes: 前述全部端点与模块

  • Produces: 端到端验证脚本

  • Step 1: 写失败测试(全链路)

# tests/test_progress_e2e.py
import threading
from fastapi.testclient import TestClient
from genesis.server.app import create_app
from genesis.server.hub import hub
from genesis.server.store import SessionStore, ProjectsStore
from pathlib import Path

_SAMPLE = Path(__file__).resolve().parents[1] / "sample"


def test_ws_progress_during_generate(tmp_path):
    client = TestClient(create_app(
        store=SessionStore(db_path=str(tmp_path / "s.db")),
        data_root=str(tmp_path / "data"), engine="fake"))
    sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
    for ft, name in [("requirements", "requirements_newdev.xlsx"),
                     ("template", "template_design_ja.docx"),
                     ("write_instruction", "rules_design_ja.docx"),
                     ("rules", "rules_entry_ja.docx")]:
        client.post(f"/api/sessions/{sid}/files", data={"file_type": ft},
                    files={"file": (name, (_SAMPLE / name).read_bytes())})

    received = []
    def trigger():
        hub.emit(sid, {"type": "progress", "step": "generate", "status": "ok", "detail": "生成完成"})

    with client.websocket_connect(f"/api/sessions/{sid}/ws") as ws:
        threading.Thread(target=trigger).start()
        data = ws.receive_json(timeout=2.0)
        received.append(data)
    assert any(e["type"] == "progress" and e["step"] == "generate" for e in received)
  • Step 2: 运行测试确认失败(应为通过,若失败则回查 Task1-4)

Run: python -m pytest tests/test_progress_e2e.py -q Expected: PASS

  • Step 3: 更新文档

docs/design.md §12 追加:「WebSocket 实时进度流(2026-08-29):新增 ProgressHub 单例 + /api/sessions/{sid}/ws 端点 + chat_ws.js;进度/错误事件实时推送,持久化兜底保留;单进程假设,多 worker 不互通。」

  • Step 4: 运行全量测试确认无回归

Run: python -m pytest -q -o addopts="" Expected: 全部通过(在现有 568 基础上新增用例)

  • Step 5: 提交
git add tests/test_progress_e2e.py docs/design.md _AI_USAGE_LOG.md
git commit -m "test(chat): WebSocket 进度流端到端冒烟 + design.md 记录"

Self-Review

  1. Spec coverage: 实时推送(Task1-3)、前端渲染(Task4)、持久化兜底保留(Task2 明确保留 _persist_progress/_store_error)、端到端验证(Task5)均覆盖。
  2. Placeholder scan: 无 TBD;每个代码步骤均给出完整实现。
  3. Type consistency: hub.emit(sid, event) 签名在 Task1/2/3/5 一致;event 结构 {type,step,status,detail} 在前端与后端一致;connectProgressWs/applyProgressEvent 在 Task4 测试与实现一致。
  4. 限制: 单进程;多 worker 跨进程不互通已在 Global Constraints 标注,后续可迭代为 Redis 总线(不在本迭代)。

GSTACK REVIEW REPORT

  • 评审技能:plan-eng-review
  • 评审对象:docs/superpowers/plans/2026-08-29-iteration-websocket-progress.md(本文件)
  • 评审结论:DONE_WITH_CONCERNS(架构决策已确认,遗留项为已知限制与前端加固点)

评审发现与处置

编号 发现 严重度 处置
F1 跨线程 emit 依赖已注册事件循环;多 worker 下 ProgressHub 无法跨进程投递事件 D2 确认单进程;多 worker 拆为后续独立项(Redis 总线),Global Constraints 已标注
F2 chat_ws.js 构造 WebSocket 未做异常保护,连接失败会抛错影响页面 Task 4 已增加 try/catch,失败静默降级(已有持久化兜底)
F3 WS 端点不消费客户端消息,需明确方向 D1 确认单向(server→client 进度推送),聊天消息保持 HTTP
F4 实时渲染进度 与「HTTP 响应 progress 字段」潜在重复渲染 确认 send() 仅渲染 assistant 回复、不渲染 progress 字段;重载走 loadSession,二者不重叠
F5 subscribers 字典跨线程读写竞争 单进程低并发可接受;如需强化可加 asyncio.Lock,本迭代不引入

已确认决策

  • D1WebSocket 单向(server→client 进度推送);聊天消息仍走 HTTP POST。
  • D2:单进程部署,ProgressHub 进程内单例;多 worker 不在本轮范围。

剩余关注

  • Task 4 前端集成(打开/关闭 WS、错误降级)仅 applyProgressEvent 有 Node 单测;connectProgressWs 集成胶水需在浏览器手测验证(文档注明)。
  • 单进程假设须在 docs/design.md §12 记录(Task 5 已含)。
  • 进度事件在 WS 未连接前(如首屏尚未打开)会被丢弃,但已持久化兜底,重载仍可见,不影响正确性。