From 1d38353498e80024ba9e4347fa24d2707be56fa3 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 4 Nov 2026 22:21:54 +0800 Subject: [PATCH] =?UTF-8?q?refactor(webui):=20SSE=20=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E7=8E=AF=E5=88=A4=E6=8D=AE=E8=BF=81=E5=88=B0=20test/=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=AF=BC=E5=87=BA=E7=8E=AF=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E4=B8=8E=E8=AE=B0=E5=BD=95=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/plugins/webui/handler.go | 2 +- internal/plugins/webui/handler_chat.go | 2 +- internal/plugins/webui/handler_sse.go | 43 ++++++++++++------- .../webui/{ => test}/handler_sse_test.go | 27 ++++++------ 4 files changed, 43 insertions(+), 31 deletions(-) rename internal/plugins/webui/{ => test}/handler_sse_test.go (77%) diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 392272e2..861701ce 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -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 diff --git a/internal/plugins/webui/handler_chat.go b/internal/plugins/webui/handler_chat.go index 958b7a1f..07f7a665 100644 --- a/internal/plugins/webui/handler_chat.go +++ b/internal/plugins/webui/handler_chat.go @@ -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)) diff --git a/internal/plugins/webui/handler_sse.go b/internal/plugins/webui/handler_sse.go index 72798941..458c805f 100644 --- a/internal/plugins/webui/handler_sse.go +++ b/internal/plugins/webui/handler_sse.go @@ -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 } diff --git a/internal/plugins/webui/handler_sse_test.go b/internal/plugins/webui/test/handler_sse_test.go similarity index 77% rename from internal/plugins/webui/handler_sse_test.go rename to internal/plugins/webui/test/handler_sse_test.go index e32c3c0d..8e64f538 100644 --- a/internal/plugins/webui/handler_sse_test.go +++ b/internal/plugins/webui/test/handler_sse_test.go @@ -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") } }