design/11 §7.1(主人 2026-10-03 拍板)。此前 hub.broadcast 是全连接广播,而任意角色
(含 viewer)都能连 /ws/chat ⇒ viewer 能收到角色回复正文(message.*)、推理内容
(thinking.*)、消息增量、graph_update、mood 等。这与 design/19 §1/§3「flow 可视化仅
owner/admin 可见」冲突,也与 GET /sessions/{id}/messages 的 owner/admin 档位不一致。
收口:唯一分类点 internal/services/gateway/ws_frame_scope.go 的 frameReachesAll(typ)
(fail-closed switch,默认 false);hub.broadcast 投递前查表,白名单外对非 owner/admin
连接直接跳过。**不给 broadcast 加 bool 参数、不让调用点自己判断** —— 那正是"漏一处就是
泄漏"的形状。全包只有一个 fan-out 循环,14 个 .broadcast( 调用点全部落进该入口。
- 白名单只有 7 个状态机信号帧(不含任何内容):session_busy / pending_ack / pending_drain /
session_idle / session_interrupted / pending_full / session_read
- 其余全部(流式 message/thinking/tool_call、message_persisted/edited/deleted、graph_update、
subagent_event/result、task_output/task_done、automation_result、tool_used、capacity_awaken、
mode_switch、mood、device_update、reminder)仅 owner+admin
- 语义澄清:本门管「谁被主动推送」,数据可见性仍由 REST 决定(viewer 的 12 条只读监控端点
不变,含 GET /personas 的 mood);/ws/chat 连接鉴权与 voice_output 定向帧不动
契约:Docs/api-client.md → v0.48(对 viewer 是收紧;owner/admin 零变化;无端点/字段形状变更)
验证:go build/vet 绿;go test ./... = 58 包 ok / 0 FAIL;-race ./internal/services/gateway/... ok;
linux/amd64 + darwin/arm64 交叉编译绿。真 httptest WS + 真 JWT 按角色逐帧实测 37 帧:
viewer 只收到 7 个状态信号,owner/admin 37/37。
变异验证:① graph_update 加回白名单 → 真 WS 抓到 viewer 收到 graph_update,3 个测试 FAIL;
② session_idle 移出白名单 → 3 个测试 FAIL(viewer 等不到哨兵帧而超时)。均已还原。
遗留(已登记,待主人裁决):① 握手 history 回放对任意角色照发(viewer 连上即得主会话最近
50 条正文);② /ws/chat 上行未按角色授权(viewer 可发 chat = 真驱动模型并落库;
subagent_ask / task_output_query 的定向应答绕过本门)。
152 lines
5.3 KiB
Go
152 lines
5.3 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
)
|
|
|
|
// scopeStreamAdapter is a model.Adapter that emits two text deltas so a test can
|
|
// drive the real loop and observe the framed events it produces.
|
|
type scopeStreamAdapter struct{}
|
|
|
|
func (scopeStreamAdapter) Name() string { return "scope-test" }
|
|
|
|
func (scopeStreamAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
|
|
return model.Response{Content: "Hel lo"}, nil
|
|
}
|
|
|
|
func (scopeStreamAdapter) ChatStream(_ context.Context, _ model.Request, on func(model.StreamEvent)) (model.Response, error) {
|
|
if on != nil {
|
|
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "Hel"})
|
|
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "lo"})
|
|
}
|
|
return model.Response{Content: "Hel lo"}, nil
|
|
}
|
|
|
|
// newScopedStreamHub wires a hub to a real loop whose OnStream closure mirrors
|
|
// cmd/cyrene's streamChunk (publish the whole StreamEvent value to ws.stream), so
|
|
// a test exercises the production path OnStream → bus → relay → frame. It returns
|
|
// the hub and a registered fake connection.
|
|
func newScopedStreamHub(t *testing.T) (*hub, *wsConn) {
|
|
t.Helper()
|
|
b := bus.New()
|
|
ag, err := agent.New("You are a helpful assistant.", nil, nil, agent.WithModel(scopeStreamAdapter{}, nil))
|
|
if err != nil {
|
|
t.Fatalf("agent.New: %v", err)
|
|
}
|
|
lp := loop.New(loop.Options{
|
|
Agent: ag,
|
|
MaxRounds: 8,
|
|
OnStream: func(ev model.StreamEvent) {
|
|
_ = b.Publish(context.Background(), wsStreamSubject, ev)
|
|
},
|
|
})
|
|
hb := newTestHub(lp, b)
|
|
hb.streaming = true
|
|
hb.start()
|
|
t.Cleanup(hb.close)
|
|
c := newOwnerConn(hb, 64)
|
|
hb.register(c)
|
|
return hb, c
|
|
}
|
|
|
|
// readDeltas scans the connection's frames until want message.delta frames have
|
|
// arrived. The deltas reach the conn asynchronously through the bus while
|
|
// runTurn's own stream_end is sent straight to the conn, so a test cannot assume
|
|
// an order.
|
|
func readDeltas(t *testing.T, c *wsConn, want int) []wsEvent {
|
|
t.Helper()
|
|
var deltas []wsEvent
|
|
deadline := time.After(3 * time.Second)
|
|
for len(deltas) < want {
|
|
select {
|
|
case raw := <-c.send:
|
|
var ev wsEvent
|
|
if err := json.Unmarshal(raw, &ev); err != nil {
|
|
t.Fatalf("decode frame %s: %v", raw, err)
|
|
}
|
|
if ev.Type == "message.delta" {
|
|
deltas = append(deltas, ev)
|
|
}
|
|
case <-deadline:
|
|
t.Fatalf("timed out with %d message.delta frames, want %d", len(deltas), want)
|
|
}
|
|
}
|
|
return deltas
|
|
}
|
|
|
|
// TestStreamFramesCarrySessionScope is the end-to-end lock for A1: a turn driven
|
|
// through the hub's runTurn must surface message.delta frames that carry the
|
|
// session and the turn id, so a client can route them to the active session. It
|
|
// runs the real loop instead of calling broadcastStream directly, which also
|
|
// proves the bus carries the new fields untouched — no publisher change needed.
|
|
func TestStreamFramesCarrySessionScope(t *testing.T) {
|
|
t.Parallel()
|
|
hb, c := newScopedStreamHub(t)
|
|
|
|
if _, err := hb.runTurn(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "hi")}, loop.OwnerEnv()); err != nil {
|
|
t.Fatalf("runTurn: %v", err)
|
|
}
|
|
deltas := readDeltas(t, c, 2)
|
|
|
|
for i, ev := range deltas {
|
|
if ev.SessionID != "web" {
|
|
t.Fatalf("delta[%d] sessionId = %q, want web (frame=%+v)", i, ev.SessionID, ev)
|
|
}
|
|
if !strings.HasPrefix(ev.TurnID, "turn-") {
|
|
t.Fatalf("delta[%d] turnId = %q, want a turn-* id (frame=%+v)", i, ev.TurnID, ev)
|
|
}
|
|
}
|
|
if deltas[0].TurnID != deltas[1].TurnID {
|
|
t.Fatalf("turnId differs within one turn: %q vs %q", deltas[0].TurnID, deltas[1].TurnID)
|
|
}
|
|
if deltas[0].Seq == 0 || deltas[1].Seq <= deltas[0].Seq {
|
|
t.Fatalf("seq not increasing: %d then %d", deltas[0].Seq, deltas[1].Seq)
|
|
}
|
|
if data, ok := deltas[0].Data.(map[string]any); !ok || data["delta"] != "Hel" {
|
|
t.Fatalf("first delta data = %+v, want delta=Hel", deltas[0].Data)
|
|
}
|
|
}
|
|
|
|
// TestStreamFramesTurnScopeChangesPerTurn covers the orchestrated resume shape: a
|
|
// pending turn drained after the first one completes is a second runTurn call, so
|
|
// it must take a fresh turnId. Without that, deltas from the resumed turn would
|
|
// be indistinguishable from the previous turn's.
|
|
func TestStreamFramesTurnScopeChangesPerTurn(t *testing.T) {
|
|
t.Parallel()
|
|
hb, c := newScopedStreamHub(t)
|
|
|
|
msgs := []model.Message{model.TextMessage(model.RoleUser, "hi")}
|
|
if _, err := hb.runTurn(context.Background(), "web", msgs, loop.OwnerEnv()); err != nil {
|
|
t.Fatalf("first runTurn: %v", err)
|
|
}
|
|
first := readDeltas(t, c, 2)
|
|
if _, err := hb.runTurn(context.Background(), "web", msgs, loop.OwnerEnv()); err != nil {
|
|
t.Fatalf("second runTurn: %v", err)
|
|
}
|
|
second := readDeltas(t, c, 2)
|
|
|
|
for i, ev := range second {
|
|
if ev.SessionID != "web" {
|
|
t.Fatalf("second turn delta[%d] sessionId = %q, want web", i, ev.SessionID)
|
|
}
|
|
if ev.TurnID != second[0].TurnID {
|
|
t.Fatalf("second turn delta[%d] turnId = %q, want %q for the whole turn", i, ev.TurnID, second[0].TurnID)
|
|
}
|
|
}
|
|
if first[0].TurnID == second[0].TurnID {
|
|
t.Fatalf("turnId reused across turns: %q", first[0].TurnID)
|
|
}
|
|
if second[0].Seq <= first[1].Seq {
|
|
t.Fatalf("seq not monotonic across turns: %d then %d", first[1].Seq, second[0].Seq)
|
|
}
|
|
}
|