refactor(webui): SSE 事件环判据迁到 test/ —— 导出环类型与记录字段

handler_sse_test.go → test/          (6 条判据)
    webui/       167 → 161
    webui/test/   35 →  41
    合计 202 不变

## 原来那 6 条判据读的是私有字段

    len(r.buf)
    r.buf[0].ID / .EventType / .Data

`sseEventRing` 的对外语义就三条:容量上限、按 Last-Event-ID 重放、
未知 ID 回退全量。它们全部可以从**导出接口**断言:

    NewSSEEventRing(cap)        构造
    Append(id, type, data)      写入
    After(id) []SSEEventRecord  重放

所以导出 `sseEventRing → SSEEventRing`、`SSEEventRecord` 的字段、
以及构造入口 `NewSSEEventRing`。

**为什么这不是"为测试导出内部"**:`After` 返回的东西就是客户端断线
重连时实际收到的内容 —— id、event、payload 三样。它们是外部可见的输出,
把字段藏起来只让包内测试能读,才是反过来的怪事。

测试侧的 `r.buf[i]` 统一换成 `r.After("")[i]`(空 ID 命中不了,按约定
回退全量),语义等价且不再触碰内部状态。

## 判据有效性(变异验证)

把容量裁剪短路掉(`if false && len(r.buf) > r.cap`):

    --- FAIL: TestSSEEventRing_Append_Capacity
        expected 3, got 5

环不再丢最旧条目 ⇒ 断线重连会重放超过窗口的历史,客户端 Last-Event-ID
语义失效。恢复后全绿。

## 验证

    go build ./...                  PASS
    go vet ./...                    PASS
    gofmt -l                        干净
    go test ./... -count=1          67 包全过,0 FAIL
This commit is contained in:
JianFeeeee
2026-11-04 22:21:54 +08:00
parent bb29096f60
commit 1d38353498
4 changed files with 43 additions and 31 deletions

View File

@ -177,7 +177,7 @@ type Handler struct {
// 由设置项 webui.trusted_proxies 配置(逗号分隔)。
trustedProxies []string
sseEvents *sseEventRing // SSE 事件环状缓冲区,Last-Event-ID 重放用
sseEvents *SSEEventRing // SSE 事件环状缓冲区,Last-Event-ID 重放用
chatMu sync.Mutex
chatHistory []ChatMsg

View File

@ -738,7 +738,7 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
} else {
log.Printf("[SSE] replay: sending %d events after id %s", len(replayed), lastEventID)
for _, rec := range replayed {
fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n", rec.id, rec.eventType, string(rec.data))
fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n", rec.ID, rec.EventType, string(rec.Data))
flusher.Flush()
}
log.Printf("[SSE] replay complete, wrote %d events", len(replayed))

View File

@ -11,47 +11,58 @@ import (
// SSE 环形缓冲:/api/v1/chat/events 断线重连时按 Last-Event-ID 补发。
// sseEventRecord 保存一条 SSE 事件元数据,供断线重连时按 Last-Event-ID 重放遗漏事件。
type sseEventRecord struct {
id string // SSE 事件 id 值(如 "1234567890-5")
eventType string // 事件类型(agent_output, reasoning 等)
data json.RawMessage // 序列化后的 payload JSON
// SSEEventRecord 保存一条 SSE 事件元数据,供断线重连时按 Last-Event-ID 重放遗漏事件。
//
// ★ 字段导出(2026-11-04):重放内容是**外部可见的输出**(客户端按 Last-Event-ID
// 拿到的就是这三样),所以字段可读不是暴露内部。类型名一并导出,好让
// webui/test 能直接断言"重放了哪些帧、每帧的 id/type/payload"。
type SSEEventRecord struct {
ID string // SSE 事件 id 值(如 "1234567890-5")
EventType string // 事件类型(agent_output, reasoning 等)
Data json.RawMessage // 序列化后的 payload JSON
}
// sseEventRing 是一个固定大小的环状缓冲区,保持最近 cap 条 SSE 事件。
type sseEventRing struct {
// SSEEventRing 是一个固定大小的环状缓冲区,保持最近 cap 条 SSE 事件。
//
// ★ 导出(2026-11-04):它的对外语义 —— 容量上限、按 Last-Event-ID 重放、
// 未知 ID 回退全量 —— 是断线重连正确性的全部依据,值得直接断言。
// 之前包内测试读 r.buf 私有字段来验证这些,属于白盒。
type SSEEventRing struct {
mu sync.Mutex
buf []sseEventRecord
buf []SSEEventRecord
cap int
}
func newSSEEventRing(cap int) *sseEventRing {
return &sseEventRing{cap: cap}
func newSSEEventRing(cap int) *SSEEventRing {
return &SSEEventRing{cap: cap}
}
// NewSSEEventRing 构造一个容量为 cap 的事件环(导出供包外契约判据使用)。
func NewSSEEventRing(cap int) *SSEEventRing { return newSSEEventRing(cap) }
// Append 追加一条事件,超过容量时丢弃最旧条目。
func (r *sseEventRing) Append(id, eventType string, data json.RawMessage) {
func (r *SSEEventRing) Append(id, eventType string, data json.RawMessage) {
r.mu.Lock()
defer r.mu.Unlock()
r.buf = append(r.buf, sseEventRecord{id: id, eventType: eventType, data: data})
r.buf = append(r.buf, SSEEventRecord{ID: id, EventType: eventType, Data: data})
if len(r.buf) > r.cap {
r.buf = r.buf[len(r.buf)-r.cap:]
}
}
// After 返回所有在指定 id 之后的事件(按写入顺序),若 id 不在缓冲区中则返回全部。
func (r *sseEventRing) After(id string) []sseEventRecord {
func (r *SSEEventRing) After(id string) []SSEEventRecord {
r.mu.Lock()
defer r.mu.Unlock()
for i := len(r.buf) - 1; i >= 0; i-- {
if r.buf[i].id == id {
result := make([]sseEventRecord, len(r.buf)-i-1)
if r.buf[i].ID == id {
result := make([]SSEEventRecord, len(r.buf)-i-1)
copy(result, r.buf[i+1:])
return result
}
}
// ID 不在缓冲区(可能是太旧或从未收到),返回全部
result := make([]sseEventRecord, len(r.buf))
result := make([]SSEEventRecord, len(r.buf))
copy(result, r.buf)
return result
}

View File

@ -1,35 +1,36 @@
package webui
package test
import (
"encoding/json"
"github.com/JianFeeeee/HologramAgent/internal/plugins/webui"
"reflect"
"testing"
)
func TestSSEEventRing_Append_Capacity(t *testing.T) {
r := newSSEEventRing(3)
r := webui.NewSSEEventRing(3)
data := json.RawMessage(`{"a":1}`)
for i := 0; i < 5; i++ {
r.Append("id-"+string(rune('0'+i)), "agent_output", data)
}
// cap=3,只保留 id-2,id-3,id-4
if len(r.buf) != 3 {
t.Fatalf("expected 3, got %d", len(r.buf))
if len(r.After("")) != 3 {
t.Fatalf("expected 3, got %d", len(r.After("")))
}
if r.buf[0].id != "id-2" {
t.Fatalf("expected id-2, got %s", r.buf[0].id)
if r.After("")[0].ID != "id-2" {
t.Fatalf("expected id-2, got %s", r.After("")[0].ID)
}
}
func TestSSEEventRing_After_Mid(t *testing.T) {
r := newSSEEventRing(10)
r := webui.NewSSEEventRing(10)
data := json.RawMessage(`{"x":"y"}`)
r.Append("id-1", "reasoning", data)
r.Append("id-2", "agent_output", data)
r.Append("id-3", "tool_call", data)
after := r.After("id-2")
if len(after) != 1 || after[0].id != "id-3" {
if len(after) != 1 || after[0].ID != "id-3" {
t.Fatalf("expected [id-3], got %v", after)
}
@ -40,7 +41,7 @@ func TestSSEEventRing_After_Mid(t *testing.T) {
}
func TestSSEEventRing_After_NotFound_ReturnsAll(t *testing.T) {
r := newSSEEventRing(3)
r := webui.NewSSEEventRing(3)
data := json.RawMessage(`{}`)
r.Append("id-1", "agent_output", data)
r.Append("id-2", "agent_output", data)
@ -52,7 +53,7 @@ func TestSSEEventRing_After_NotFound_ReturnsAll(t *testing.T) {
}
func TestSSEEventRing_After_Last_Tip(t *testing.T) {
r := newSSEEventRing(5)
r := webui.NewSSEEventRing(5)
data := json.RawMessage(`{"a":1}`)
for i := 0; i < 5; i++ {
r.Append("id-"+string(rune('0'+i)), "agent_output", data)
@ -64,7 +65,7 @@ func TestSSEEventRing_After_Last_Tip(t *testing.T) {
}
func TestSSEEventRing_Concurrent(t *testing.T) {
r := newSSEEventRing(100)
r := webui.NewSSEEventRing(100)
done := make(chan struct{})
for i := 0; i < 8; i++ {
go func(n int) {
@ -89,8 +90,8 @@ func TestSSEEventRing_Concurrent(t *testing.T) {
}
func TestSSEEventRecord_Type(t *testing.T) {
var got interface{} = sseEventRecord{}
var got interface{} = webui.SSEEventRecord{}
if reflect.TypeOf(got).Kind() != reflect.Struct {
t.Fatal("sseEventRecord should be a struct")
t.Fatal("webui.SSEEventRecord should be a struct")
}
}