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") } }