docs(gateway): WS 帧门相关 11 个文件的注释改中文(只改注释,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」。本次先把本会话动过的文件就地翻; 不做全仓一次性翻译,存量按包分批排期(见 工作小结.md 待办)。 范围:ws_frame_scope.go / ws_frame_scope_test.go / broadcast.go / ws_hub_turn.go / ws_hub_message_event.go / session_read.go / server.go / ws_hub_stream_scope_test.go / history_write_broadcast_test.go / mood_test.go / extra_test.go 含中文的整行注释 18 → 382。 校验(判据 = "只改注释、零代码字节改动"): - `git diff -U0 | 排除 +++/--- | 排除注释行/空行/行尾注释` → 为空 - 独立实现:两侧剥掉注释后逐行比对代码文本 → 11/11 文件全等,不一致数 0 - 行尾注释 7 行逐条列出,代码列与原文逐字节相同(gofmt 未要求重排) - gofmt -l 干净;全树 go build/vet 绿;go test ./... = 58 包 ok / 0 FAIL; linux/amd64 + darwin/arm64 交叉编译绿 语义未删减:部分折行英文长句合并为更少行,故整行注释数 438 → 399。
This commit is contained in:
1 parent
770a83c3fb
commit
fe1ff33115
11 files changed
+392
-431
No files matched your search
@@ -6,23 +6,20 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
)
|
||||
|
||||
// This file owns the gateway's outbound realtime pushes: the persisted-message
|
||||
// frames shared with the channel bridge plus the per-persona state frames the
|
||||
// frontend renders (mood badge, capacity awaken).
|
||||
// 本文件归属 gateway 的下行实时推送:与 channel bridge 共用的
|
||||
// 持久化消息帧,以及前端渲染的角色级状态帧(心情徽标、能力唤醒)。
|
||||
|
||||
// BroadcastPersisted broadcasts a just-persisted chat message as a WS event so
|
||||
// every browser of the same account appends it without a manual refresh
|
||||
// (design/16 §4.2). It is shared by the orchestrator (Web turns) and the channel
|
||||
// bridge (IM messages), so both paths flow through one frame shape. The event type
|
||||
// is split by session: the main web session and its web sub-sessions broadcast
|
||||
// `message_persisted` for web-chat realtime sync, while any other session id is an
|
||||
// isolated channel adapter session (sess-xxx) and broadcasts `channel_persisted`
|
||||
// for the IM adapter log page — the web chat has no handler for the latter, so it
|
||||
// is naturally ignored there and never pollutes the conversation. Thinking is
|
||||
// stripped to stay consistent with the WS history replay (design/16 §6). It is a
|
||||
// no-op-safe call when the hub has broadcast wired. Both frame types carry message
|
||||
// content, so the design/11 §7.1 frame gate delivers them to owner/admin
|
||||
// connections only.
|
||||
// BroadcastPersisted 把一条刚刚持久化的聊天消息作为 WS 事件广播出去,
|
||||
// 使同一账号的每个浏览器无需手动刷新即可追加该消息
|
||||
// (design/16 §4.2)。它由 orchestrator(Web 轮次)与 channel bridge
|
||||
// (IM 消息)共用,因此两条路径走同一种帧结构。事件类型按会话
|
||||
// 区分:Web 主会话及其 Web 子会话广播 `message_persisted` 用于
|
||||
// Web 聊天实时同步,而其他任何会话 id 都是隔离的渠道适配器会话
|
||||
// (sess-xxx),广播 `channel_persisted` 供 IM 适配器日志页使用
|
||||
// —— Web 聊天没有后者的处理器,因此在那里自然被忽略、永不污染
|
||||
// 会话。Thinking 会被抹除,以与 WS 历史回放保持一致(design/16 §6)。
|
||||
// 当 hub 接好广播时它是安全空操作。两种帧类型都携带消息内容,
|
||||
// 因此 design/11 §7.1 帧门只把它们投递给 owner/admin 连接。
|
||||
func (s *Server) BroadcastPersisted(msg history.Message) {
|
||||
wire := toHistoryMessage(msg)
|
||||
wire.Thinking = ""
|
||||
@@ -33,18 +30,18 @@ func (s *Server) BroadcastPersisted(msg history.Message) {
|
||||
s.hub.broadcast(wsEvent{Type: typ, SessionID: msg.SessionID, Data: map[string]any{"message": wire}})
|
||||
}
|
||||
|
||||
// wsCapacityAwaken is the WS event type for a capacity state transition
|
||||
// (design/14 §5). Its data field is the frontend AwakenPayload {fromState,toState,tool}.
|
||||
// wsCapacityAwaken 是能力状态迁移的 WS 事件类型(design/14 §5)。
|
||||
// 其 data 字段是前端的 AwakenPayload {fromState,toState,tool}。
|
||||
const wsCapacityAwaken = "capacity_awaken"
|
||||
|
||||
// BroadcastCapacityAwaken fans a capacity state transition to the Web UI as a
|
||||
// `capacity_awaken` frame (design/14 §5). sessionID is the conversation that
|
||||
// transitioned, so the client targets the correct session rather than treating the
|
||||
// wake event as global. data matches the frontend AwakenPayload {fromState, toState,
|
||||
// tool}; tool may be empty (no single tool was lit, the client then keeps the prior
|
||||
// tool badge). It is NOT one of the design/11 §7.1 「状态机信号」 (its `tool` names
|
||||
// what woke, and the panel that renders it is an owner surface), so the frame gate
|
||||
// delivers it to owner/admin connections only.
|
||||
// BroadcastCapacityAwaken 把一次能力状态迁移作为 `capacity_awaken` 帧
|
||||
// 扇出到 Web UI(design/14 §5)。sessionID 是发生迁移的会话,因此
|
||||
// 客户端能定位到正确的会话,而不会把唤醒事件当成全局事件。data 与
|
||||
// 前端 AwakenPayload {fromState, toState, tool} 对应;tool 可以为空
|
||||
// (没有点名单个工具,客户端此时保留原有的工具徽标)。它不属于
|
||||
// design/11 §7.1 的「状态机信号」(其 `tool` 指明的是被什么唤醒,而
|
||||
// 渲染它的面板属于 owner 界面),因此帧门只把它投递给 owner/admin
|
||||
// 连接。
|
||||
func (s *Server) BroadcastCapacityAwaken(sessionID, fromState, toState, tool string) {
|
||||
s.hub.broadcast(wsEvent{
|
||||
Type: wsCapacityAwaken,
|
||||
@@ -57,21 +54,19 @@ func (s *Server) BroadcastCapacityAwaken(sessionID, fromState, toState, tool str
|
||||
})
|
||||
}
|
||||
|
||||
// wsMoodEvent is the WS event type carrying the persona's mood after a turn
|
||||
// (design/07 §3 下行 `mood`; design/01 §2 情绪状态机). The frontend's existing
|
||||
// `mood` handler (ws.ts) reads data.mood and shows the 心情 badge.
|
||||
// wsMoodEvent 是携带角色轮次结束后心情的 WS 事件类型(design/07 §3
|
||||
// 下行 `mood`;design/01 §2 情绪状态机)。前端既有的 `mood` 处理器
|
||||
// (ws.ts)读取 data.mood 并显示心情徽标。
|
||||
const wsMoodEvent = "mood"
|
||||
|
||||
// BroadcastMood fans one emotion change to the owner/admin Web clients as a `mood`
|
||||
// frame (design/07 §3). Persona mood/affection is private state, so the design/11
|
||||
// §7.1 frame gate keeps it off viewer connections. It is called only when the mood
|
||||
// actually changed — the
|
||||
// machinery in the composition root is the gate — so the badge does not flicker
|
||||
// with an unchanged value on every turn. data matches what the frontend `mood`
|
||||
// handler reads ({mood}); affection/personaId ride along as the honest extra
|
||||
// detail (affection is the persona's current 好感度, personaId the persona whose
|
||||
// mood moved) and are ignored by clients that do not render them. The mood is a
|
||||
// persona-level value, not a per-session one, so no sessionId is attached.
|
||||
// BroadcastMood 把一次情绪变化作为 `mood` 帧扇出到 owner/admin 的 Web
|
||||
// 客户端(design/07 §3)。角色心情/好感度属于私有状态,因此 design/11
|
||||
// §7.1 帧门让它远离 viewer 连接。它仅在心情确实发生变化时被调用
|
||||
// —— 组合根中的机制就是那道门 —— 因此徽标不会因为每轮都出现未变化
|
||||
// 的值而闪烁。data 与前端 `mood` 处理器读取的内容对应({mood});
|
||||
// affection/personaId 作为诚实的额外细节一同携带(affection 是该角色
|
||||
// 当前好感度,personaId 是心情发生变化的角色),不渲染它们的客户端
|
||||
// 会忽略之。心情是角色级而非会话级的值,因此不附加 sessionId。
|
||||
func (s *Server) BroadcastMood(personaID, mood string, affection int) {
|
||||
s.hub.broadcast(wsEvent{
|
||||
Type: wsMoodEvent,
|
||||
|
||||
@@ -36,13 +36,13 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// cfgDefault builds the minimum config used across tests.
|
||||
// cfgDefault 构造各测试共用的最小配置。
|
||||
func cfgDefault() *config.Config {
|
||||
return &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
|
||||
}
|
||||
|
||||
// buildEnv constructs a Server + httptest server from an explicit cfg/deps,
|
||||
// defaulting Auth to a real JWTService and Bus to a real in-process bus.
|
||||
// buildEnv 由显式的 cfg/deps 构造 Server + httptest server,Auth 默认为
|
||||
// 真实 JWTService,Bus 默认为真实的进程内 bus。
|
||||
func buildEnv(t *testing.T, cfg *config.Config, deps Deps) (*Server, *httptest.Server, *auth.JWTService) {
|
||||
t.Helper()
|
||||
if cfg == nil {
|
||||
@@ -64,8 +64,8 @@ func buildEnv(t *testing.T, cfg *config.Config, deps Deps) (*Server, *httptest.S
|
||||
return srv, ts, jwt
|
||||
}
|
||||
|
||||
// buildServerOnly constructs a Server without an httptest server (used for
|
||||
// start/shutdown lifecycle tests).
|
||||
// buildServerOnly 构造不带 httptest server 的 Server(用于启动/关闭
|
||||
// 生命周期测试)。
|
||||
func buildServerOnly(t *testing.T, cfg *config.Config, deps Deps) *Server {
|
||||
t.Helper()
|
||||
srv, err := New(cfg, deps)
|
||||
@@ -75,13 +75,13 @@ func buildServerOnly(t *testing.T, cfg *config.Config, deps Deps) *Server {
|
||||
return srv
|
||||
}
|
||||
|
||||
// mustJWT builds a real JWTService for a test secret.
|
||||
// mustJWT 为测试密钥构造真实 JWTService。
|
||||
func mustJWT(t *testing.T) *auth.JWTService {
|
||||
t.Helper()
|
||||
return auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||||
}
|
||||
|
||||
// signAny issues a token for sub under the given role via a real JWTService.
|
||||
// signAny 经真实 JWTService 为 sub 签发指定角色的 token。
|
||||
func signAny(t *testing.T, jwt *auth.JWTService, sub string, role auth.Role) string {
|
||||
t.Helper()
|
||||
tok, err := jwt.Sign(sub, role, time.Hour)
|
||||
@@ -91,7 +91,7 @@ func signAny(t *testing.T, jwt *auth.JWTService, sub string, role auth.Role) str
|
||||
return tok
|
||||
}
|
||||
|
||||
// doRaw performs a request with a raw (already-encoded) body.
|
||||
// doRaw 用原始(已编码)请求体发起请求。
|
||||
func doRaw(t *testing.T, method, url, raw, token string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, url, strings.NewReader(raw))
|
||||
@@ -114,9 +114,9 @@ func doRaw(t *testing.T, method, url, raw, token string) (*http.Response, []byte
|
||||
return resp, b
|
||||
}
|
||||
|
||||
// --- Mocks ------------------------------------------------------------------
|
||||
// --- 测试替身(mock) ---------------------------------------------------------
|
||||
|
||||
// stubAuth wraps a real JWTService but lets Sign/Verify fail on demand.
|
||||
// stubAuth 包装真实 JWTService,但可按需让 Sign/Verify 失败。
|
||||
type stubAuth struct {
|
||||
jwt *auth.JWTService
|
||||
signErr error
|
||||
@@ -141,7 +141,7 @@ func (s *stubAuth) Authorize(required ...auth.Role) func(http.Handler) http.Hand
|
||||
return s.jwt.Authorize(required...)
|
||||
}
|
||||
|
||||
// stubCaps mimics CapabilityStore with a fixed list and a forced SetPolicy error.
|
||||
// stubCaps 以固定列表和强制出错的 SetPolicy 模拟 CapabilityStore。
|
||||
type stubCaps struct {
|
||||
list []capreg.Capability
|
||||
setErr error
|
||||
@@ -167,7 +167,7 @@ func (s *stubCaps) SetPolicy(name string, p capreg.ToolPolicy) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// stubSessions mimics SessionStore.
|
||||
// stubSessions 模拟 SessionStore。
|
||||
type stubSessions struct {
|
||||
list []session.Session
|
||||
err error
|
||||
@@ -175,20 +175,20 @@ type stubSessions struct {
|
||||
|
||||
func (s *stubSessions) List(ctx context.Context) ([]session.Session, error) { return s.list, s.err }
|
||||
|
||||
// stubPersonas mimics PersonaStore, returning domain characters that the gateway
|
||||
// maps to PersonaSummary.
|
||||
// stubPersonas 模拟 PersonaStore,返回 gateway 会映射为 PersonaSummary
|
||||
// 的领域角色对象。
|
||||
type stubPersonas struct {
|
||||
list []personareg.Character
|
||||
}
|
||||
|
||||
func (s *stubPersonas) List() []personareg.Character { return s.list }
|
||||
|
||||
// Persona satisfies PersonaStore; tests don't rely on visual assets, so it
|
||||
// always reports no persona found (empty visuals, safe fallback).
|
||||
// Persona 满足 PersonaStore;测试不依赖视觉素材,因此它总是报告未找到
|
||||
// 角色(空视觉,安全回退)。
|
||||
func (s *stubPersonas) Persona(string) (*loader.Persona, bool) { return nil, false }
|
||||
|
||||
// stubMemories mimics MemoryStore, returning domain memory items that the gateway
|
||||
// maps to MemorySummary.
|
||||
// stubMemories 模拟 MemoryStore,返回 gateway 会映射为 MemorySummary
|
||||
// 的领域记忆条目。
|
||||
type stubMemories struct {
|
||||
list []memory.MemoryItem
|
||||
err error
|
||||
@@ -196,8 +196,8 @@ type stubMemories struct {
|
||||
|
||||
func (s *stubMemories) List(ctx context.Context) ([]memory.MemoryItem, error) { return s.list, s.err }
|
||||
|
||||
// stubRag mimics RagStore, returning domain docs/hits the gateway maps to its
|
||||
// admin wire shape.
|
||||
// stubRag 模拟 RagStore,返回 gateway 会映射为其 admin 传输结构的
|
||||
// 领域文档/命中。
|
||||
type stubRag struct {
|
||||
docs []knowledge.Doc
|
||||
hits []knowledge.Hit
|
||||
@@ -211,7 +211,7 @@ func (s *stubRag) Search(ctx context.Context, query string) ([]knowledge.Hit, er
|
||||
func (s *stubRag) IndexDocument(ctx context.Context, doc knowledge.Doc) error { return s.err }
|
||||
func (s *stubRag) DeleteDocument(ctx context.Context, id string) error { return s.err }
|
||||
|
||||
// stubIoT mimics IoTStore.
|
||||
// stubIoT 模拟 IoTStore。
|
||||
type stubIoT struct {
|
||||
list []iot.Device
|
||||
err error
|
||||
@@ -219,7 +219,7 @@ type stubIoT struct {
|
||||
|
||||
func (s *stubIoT) List(ctx context.Context) ([]iot.Device, error) { return s.list, s.err }
|
||||
|
||||
// stubAuditor mimics SubagentAuditor for the subagent_ask route.
|
||||
// stubAuditor 为 subagent_ask 路由模拟 SubagentAuditor。
|
||||
type stubAuditor struct {
|
||||
res *orchestration.AuditResult
|
||||
err error
|
||||
@@ -229,7 +229,7 @@ func (s *stubAuditor) AuditContext(ctx context.Context, id string, mode orchestr
|
||||
return s.res, s.err
|
||||
}
|
||||
|
||||
// stubTaskOutputs mimics TaskOutputStore for the task_output_query route.
|
||||
// stubTaskOutputs 为 task_output_query 路由模拟 TaskOutputStore。
|
||||
type stubTaskOutputs struct {
|
||||
ev task.OutputEvent
|
||||
err error
|
||||
@@ -239,7 +239,7 @@ func (s *stubTaskOutputs) TaskOutput(ctx context.Context, id string) (task.Outpu
|
||||
return s.ev, s.err
|
||||
}
|
||||
|
||||
// stubChannel implements sdk.Channel.
|
||||
// stubChannel 实现 sdk.Channel。
|
||||
type stubChannel struct{}
|
||||
|
||||
func (stubChannel) Name() string { return "qq" }
|
||||
@@ -250,8 +250,8 @@ func (stubChannel) HealthCheck(ctx context.Context) error { return nil }
|
||||
|
||||
var _ sdk.Channel = stubChannel{}
|
||||
|
||||
// stubBotChannel mimics a live kook adapter that resolved its bot identity via
|
||||
// GET /api/v3/user/me on Connect (surfaced through Config()).
|
||||
// stubBotChannel 模拟一个在 Connect 时经 GET /api/v3/user/me 解析出
|
||||
// 机器人身份的在线 kook 适配器(经 Config() 暴露)。
|
||||
type stubBotChannel struct{}
|
||||
|
||||
func (stubBotChannel) Name() string { return "kook:conf0" }
|
||||
@@ -265,7 +265,7 @@ func (stubBotChannel) Config() map[string]any {
|
||||
|
||||
var _ sdk.Channel = stubBotChannel{}
|
||||
|
||||
// captureRunner records the sessionID each Run call sees.
|
||||
// captureRunner 记录每次 Run 调用看到的 sessionID。
|
||||
type captureRunner struct {
|
||||
got []string
|
||||
res *loop.Result
|
||||
@@ -277,14 +277,14 @@ func (c *captureRunner) Run(ctx context.Context, sessionID string, messages []mo
|
||||
return c.res, c.err
|
||||
}
|
||||
|
||||
// plainWriter is a bare ResponseWriter implementing neither Flusher nor Hijacker.
|
||||
// plainWriter 是既未实现 Flusher 也未实现 Hijacker 的裸 ResponseWriter。
|
||||
type plainWriter struct{}
|
||||
|
||||
func (plainWriter) Header() http.Header { return http.Header{} }
|
||||
func (plainWriter) Write(b []byte) (int, error) { return len(b), nil }
|
||||
func (plainWriter) WriteHeader(code int) {}
|
||||
|
||||
// --- Server construction / validation --------------------------------------
|
||||
// --- Server 构造 / 校验 -------------------------------------------------------
|
||||
|
||||
func TestNewValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -297,7 +297,7 @@ func TestNewValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Login ------------------------------------------------------------------
|
||||
// --- 登录 --------------------------------------------------------------------
|
||||
|
||||
func TestLoginBadBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -321,7 +321,7 @@ func TestLoginSignError(t *testing.T) {
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
// --- Chat -------------------------------------------------------------------
|
||||
// --- 聊天 --------------------------------------------------------------------
|
||||
|
||||
func TestChatBadBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -356,8 +356,8 @@ func TestChatRequestPersistsMode(t *testing.T) {
|
||||
History: hist,
|
||||
})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
// The REST body carries the client's behavior mode (design/16 §5.1); the
|
||||
// orchestrator persists both the user turn and the assistant reply under it.
|
||||
// REST 请求体携带客户端的行为模式(design/16 §5.1);orchestrator 在
|
||||
// 它之下同时持久化用户轮次与助手回复。
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/chat",
|
||||
map[string]any{"sessionId": "web", "text": "改代码", "mode": "code"}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -400,7 +400,7 @@ func TestChatRunnerError(t *testing.T) {
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
// --- List handlers ---------------------------------------------------------
|
||||
// --- 列表处理器 --------------------------------------------------------------
|
||||
|
||||
func TestListSessionsNonEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -523,7 +523,7 @@ func TestAdminMemoriesErrorStillOK(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Admin: IoT ------------------------------------------------------------
|
||||
// --- Admin:IoT --------------------------------------------------------------
|
||||
|
||||
func TestAdminIoTNonEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -568,7 +568,7 @@ func TestAdminIoTError(t *testing.T) {
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
// --- Admin: channels -------------------------------------------------------
|
||||
// --- Admin:渠道 -------------------------------------------------------------
|
||||
|
||||
func TestAdminChannelsFromRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -576,7 +576,7 @@ func TestAdminChannelsFromRegistry(t *testing.T) {
|
||||
if err := env.caps.RegisterChannel(stubChannel{}); err != nil {
|
||||
t.Fatalf("register channel: %v", err)
|
||||
}
|
||||
// A channel-kind capability whose Value is not an sdk.Channel must be skipped.
|
||||
// Value 不是 sdk.Channel 的 channel 类能力必须被跳过。
|
||||
if err := env.caps.Register(capreg.Capability{Name: "badchan", Kind: capreg.KindChannel, Value: stubTool{}}); err != nil {
|
||||
t.Fatalf("register bad channel: %v", err)
|
||||
}
|
||||
@@ -597,9 +597,9 @@ func TestAdminChannelsFromRegistry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminChannels_SurfacesBotIdentity verifies a connected kook adapter's
|
||||
// resolved bot identity (from user/me) reaches the accounts of GET /admin/channels
|
||||
// so the panel can show who the bot is.
|
||||
// TestAdminChannels_SurfacesBotIdentity 验证已连接 kook 适配器解析出的
|
||||
// 机器人身份(来自 user/me)能到达 GET /admin/channels 的 accounts,
|
||||
// 使面板能展示机器人是谁。
|
||||
func TestAdminChannels_SurfacesBotIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
caps := capreg.New()
|
||||
@@ -637,11 +637,11 @@ func TestAdminChannels_SurfacesBotIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Admin: policy errors --------------------------------------------------
|
||||
// --- Admin:策略错误 ---------------------------------------------------------
|
||||
|
||||
func TestAdminSetPolicyUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no Registry
|
||||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // 不接 Registry
|
||||
_ = srv
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/plugins/policy", map[string]any{"name": "echo"}, tok)
|
||||
@@ -686,7 +686,7 @@ func TestAdminSetPolicyMissingName(t *testing.T) {
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
// --- CORS ------------------------------------------------------------------
|
||||
// --- 跨域(CORS) -------------------------------------------------------------
|
||||
|
||||
func TestCORSAllowAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -748,7 +748,7 @@ func TestCORSPreflight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recovery + statusRecorder --------------------------------------------
|
||||
// --- panic 恢复 + statusRecorder ---------------------------------------------
|
||||
|
||||
func TestRecoverPanicTo500(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -778,17 +778,17 @@ func TestStatusRecorderHijackUnsupported(t *testing.T) {
|
||||
|
||||
func TestStatusRecorderFlush(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Flusher present → forwarded.
|
||||
// 有 Flusher → 转发。
|
||||
rr := httptest.NewRecorder()
|
||||
(&statusRecorder{ResponseWriter: rr}).Flush()
|
||||
if !rr.Flushed {
|
||||
t.Fatal("Flush should have been forwarded to the underlying Flusher")
|
||||
}
|
||||
// No Flusher → no-op, no panic.
|
||||
// 无 Flusher → 空操作,不 panic。
|
||||
(&statusRecorder{ResponseWriter: plainWriter{}}).Flush()
|
||||
}
|
||||
|
||||
// --- SPA -------------------------------------------------------------------
|
||||
// --- 单页应用(SPA) ---------------------------------------------------------
|
||||
|
||||
func TestSPA(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -799,29 +799,29 @@ func TestSPA(t *testing.T) {
|
||||
srv, ts, _ := buildEnv(t, cfgDefault(), Deps{Frontend: front})
|
||||
_ = srv
|
||||
|
||||
// Real asset is served.
|
||||
// 真实静态资源被正常服务。
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/assets/app.js", nil, "")
|
||||
if resp.StatusCode != http.StatusOK || string(body) != "var x = 1;" {
|
||||
t.Fatalf("asset status = %d body = %q, want 200 with js", resp.StatusCode, body)
|
||||
}
|
||||
// Unknown non-API path falls back to index.html.
|
||||
// 未知的非 API 路径回退到 index.html。
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+"/chat/somewhere", nil, "")
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "root") {
|
||||
t.Fatalf("fallback status = %d body = %q, want 200 with index", resp.StatusCode, body)
|
||||
}
|
||||
// Root serves index.html.
|
||||
// 根路径提供 index.html。
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+"/", nil, "")
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "root") {
|
||||
t.Fatalf("root status = %d body = %q, want 200 with index", resp.StatusCode, body)
|
||||
}
|
||||
// API route is passed through untouched.
|
||||
// API 路由原样透传。
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/health", nil, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("api status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// --- WS: validation --------------------------------------------------------
|
||||
// --- WS:校验 ----------------------------------------------------------------
|
||||
|
||||
func TestWSRequiresGET(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -892,7 +892,7 @@ func TestServerCloseShutsHub(t *testing.T) {
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
// --- WS: hub unit tests ----------------------------------------------------
|
||||
// --- WS:hub 单元测试 --------------------------------------------------------
|
||||
|
||||
func TestHubStreamChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -919,9 +919,8 @@ func TestHubStreamChatPersistsMode(t *testing.T) {
|
||||
},
|
||||
History: hist,
|
||||
})
|
||||
// The ws.chat payload carries the client's behavior mode (design/16 §5.1);
|
||||
// streamChat threads it into the orchestrator, which persists both the user
|
||||
// turn and the assistant reply under it.
|
||||
// ws.chat 载荷携带客户端的行为模式(design/16 §5.1);streamChat 把它
|
||||
// 串进 orchestrator,后者在它之下持久化用户轮次与助手回复二者。
|
||||
hb.streamChat(context.Background(), map[string]any{"sessionId": "web", "text": "改代码", "mode": "code"})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
@@ -946,11 +945,11 @@ func TestHubBroadcastStream(t *testing.T) {
|
||||
c := newOwnerConn(hb, 32)
|
||||
hb.register(c)
|
||||
|
||||
// A text_delta StreamEvent on the OnStream→bus→relay path becomes a typed
|
||||
// message.delta frame carrying the delta text and a monotonically increasing
|
||||
// seq, leaving the legacy stream_chunk shape out of the way. The loop stamps
|
||||
// the session/turn identity on the event and the hub must carry it onto the
|
||||
// frame, so a client can route the delta to the active session (design/16 §4.2).
|
||||
// OnStream→bus→relay 路径上的 text_delta StreamEvent 变成带类型的
|
||||
// message.delta 帧,携带增量文本与单调递增的 seq,让旧的
|
||||
// stream_chunk 形态不再参与。loop 把会话/轮次身份盖在事件上,
|
||||
// hub 必须把它带到帧上,使客户端能把增量路由到活跃会话
|
||||
// (design/16 §4.2)。
|
||||
hb.broadcastStream(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "Hel", Index: 0, SessionID: "web", TurnID: "turn-abc"})
|
||||
ev := readFakeFrame(t, c)
|
||||
if ev.Type != "message.delta" {
|
||||
@@ -967,7 +966,7 @@ func TestHubBroadcastStream(t *testing.T) {
|
||||
t.Fatalf("frame scope = session %q turn %q, want session web turn turn-abc", ev.SessionID, ev.TurnID)
|
||||
}
|
||||
|
||||
// A tool_call event maps to tool_call.*; a non-StreamEvent payload is dropped.
|
||||
// tool_call 事件映射为 tool_call.*;非 StreamEvent 的载荷被丢弃。
|
||||
hb.broadcastStream(model.StreamEvent{Kind: model.StreamKindToolCallEnd, ID: "toolu_1", Index: 0})
|
||||
ev = readFakeFrame(t, c)
|
||||
if ev.Type != "tool_call.end" {
|
||||
@@ -999,11 +998,10 @@ func TestServerBroadcastCapacityAwaken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerBroadcastPersistedSplit verifies BroadcastPersisted routes the event
|
||||
// type by session: the main web session and its web sub-sessions broadcast
|
||||
// `message_persisted` (web chat realtime sync), while an isolated channel adapter
|
||||
// session (sess-xxx) broadcasts `channel_persisted` (IM adapter log page) so it is
|
||||
// naturally ignored by the web chat handler.
|
||||
// TestServerBroadcastPersistedSplit 验证 BroadcastPersisted 按会话路由事件
|
||||
// 类型:Web 主会话及其 Web 子会话广播 `message_persisted`(Web 聊天实时
|
||||
// 同步),而隔离的渠道适配器会话(sess-xxx)广播 `channel_persisted`
|
||||
// (IM 适配器日志页),因此它被 Web 聊天处理器自然忽略。
|
||||
func TestServerBroadcastPersistedSplit(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, false)
|
||||
@@ -1049,7 +1047,7 @@ func TestServerBroadcastPersistedSplit(t *testing.T) {
|
||||
|
||||
func TestHubStreamChatIdle(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb := newTestHub(nil, nil) // nil runner
|
||||
hb := newTestHub(nil, nil) // runner 为 nil
|
||||
c := newOwnerConn(hb, 32)
|
||||
hb.register(c)
|
||||
|
||||
@@ -1080,7 +1078,7 @@ func TestHubRelayChatBranch(t *testing.T) {
|
||||
if err := hb.relay(context.Background(), bus.Event{Subject: wsChatSubject, Payload: map[string]any{"sessionId": "s1", "text": "hi"}}); err != nil {
|
||||
t.Fatalf("relay: %v", err)
|
||||
}
|
||||
// relay runs streamChat in a goroutine; read with a deadline.
|
||||
// relay 在 goroutine 中跑 streamChat;读取时带超时。
|
||||
if ev := readFakeFrame(t, c); ev.Type != "stream_chunk" {
|
||||
t.Fatalf("frame type = %q, want stream_chunk (frame=%+v)", ev.Type, ev)
|
||||
}
|
||||
@@ -1102,12 +1100,12 @@ func TestHubRoute(t *testing.T) {
|
||||
c := newOwnerConn(hb, 32)
|
||||
hb.register(c)
|
||||
|
||||
// subagent_ask → subagent_event (real audit data, not a hardcoded pending).
|
||||
// subagent_ask → subagent_event(真实审计数据,不是硬编码的 pending)。
|
||||
c.route(wsInbound{Type: "subagent_ask", Data: json.RawMessage(`{"id":"sub-1"}`)})
|
||||
if ev := readFakeFrame(t, c); ev.Type != "subagent_event" {
|
||||
t.Fatalf("subagent frame type = %q, want subagent_event", ev.Type)
|
||||
}
|
||||
// subagent_ask with no auditor → clear error, never a fabricated pending.
|
||||
// 无 auditor 的 subagent_ask → 明确报错,绝不伪造 pending。
|
||||
hb.auditor = nil
|
||||
c.route(wsInbound{Type: "subagent_ask", Data: json.RawMessage(`{"id":"sub-1"}`)})
|
||||
if ev := readFakeFrame(t, c); ev.Type != "error" {
|
||||
@@ -1115,12 +1113,12 @@ func TestHubRoute(t *testing.T) {
|
||||
}
|
||||
hb.auditor = &stubAuditor{res: &orchestration.AuditResult{}}
|
||||
|
||||
// task_output_query → task_output with real output.
|
||||
// task_output_query → 带真实输出的 task_output。
|
||||
c.route(wsInbound{Type: "task_output_query", Data: json.RawMessage(`{"taskId":"task-1"}`)})
|
||||
if ev := readFakeFrame(t, c); ev.Type != "task_output" {
|
||||
t.Fatalf("task frame type = %q, want task_output", ev.Type)
|
||||
}
|
||||
// task_output_query with no task store → clear error, never an empty {}.
|
||||
// 无 task store 的 task_output_query → 明确报错,绝不返回空的 {}。
|
||||
hb.taskOutputs = nil
|
||||
c.route(wsInbound{Type: "task_output_query", Data: json.RawMessage(`{"taskId":"task-1"}`)})
|
||||
if ev := readFakeFrame(t, c); ev.Type != "error" {
|
||||
@@ -1128,24 +1126,24 @@ func TestHubRoute(t *testing.T) {
|
||||
}
|
||||
hb.taskOutputs = &stubTaskOutputs{ev: task.OutputEvent{ID: "task-1", Status: task.StatusDone, Output: task.Output{Stdout: "out"}}}
|
||||
|
||||
// voice_input with no voice service → clear error, never a crash or a drop.
|
||||
// 无语音服务的 voice_input → 明确报错,绝不崩溃也不丢弃。
|
||||
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"x"}`)})
|
||||
if ev := readFakeFrame(t, c); ev.Type != "error" {
|
||||
t.Fatalf("no-voice voice_input frame type = %q, want error", ev.Type)
|
||||
}
|
||||
// unknown → error (enqueueJSON).
|
||||
// 未知类型 → 报错(enqueueJSON)。
|
||||
c.route(wsInbound{Type: "nope"})
|
||||
if ev := readFakeFrame(t, c); ev.Type != "error" {
|
||||
t.Fatalf("unknown frame type = %q, want error", ev.Type)
|
||||
}
|
||||
// chat → publishes to bus (publishPublic non-nil path); no frame enqueued.
|
||||
// chat → 发布到 bus(publishPublic 非 nil 路径);不入队任何帧。
|
||||
c.route(wsInbound{Type: "chat", Data: json.RawMessage(`{"text":"hi"}`)})
|
||||
if err := connHasNoFrame(c); err != nil {
|
||||
t.Fatalf("chat should not enqueue a frame directly: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// stubVoice is a deterministic VoiceService for voice route tests.
|
||||
// stubVoice 是供语音路由测试使用的确定性 VoiceService。
|
||||
type stubVoice struct {
|
||||
sttText string
|
||||
sttErr error
|
||||
@@ -1164,9 +1162,9 @@ func TestHubVoiceInputTurn(t *testing.T) {
|
||||
c := newOwnerConn(hb, 32)
|
||||
hb.register(c)
|
||||
|
||||
// "aGVsbG8=" decodes to "hello" — a valid base64 audio payload.
|
||||
// "aGVsbG8=" 解码为 "hello" —— 合法的 base64 音频载荷。
|
||||
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"aGVsbG8=","format":"wav"}`)})
|
||||
// runVoiceTurn runs in a goroutine; read with a deadline.
|
||||
// runVoiceTurn 在 goroutine 中运行;读取时带超时。
|
||||
ev := readFakeFrame(t, c)
|
||||
if ev.Type != "voice_output" {
|
||||
t.Fatalf("voice frame type = %q, want voice_output (frame=%+v)", ev.Type, ev)
|
||||
@@ -1218,7 +1216,7 @@ func TestHubBridgeSubagentProgress(t *testing.T) {
|
||||
t.Fatalf("type = %q, want subagent_event (frame=%+v)", ev.Type, ev)
|
||||
}
|
||||
|
||||
// An internal ask subject must never surface to clients.
|
||||
// 内部的 ask 主题绝不可暴露给客户端。
|
||||
if err := hb.relay(context.Background(), bus.Event{
|
||||
Subject: "cyrene.agent.a.b.ask",
|
||||
Payload: comm.Event{Name: orchestration.EventDone, Data: orchestration.Progress{AgentID: "sub-1"}},
|
||||
@@ -1232,9 +1230,9 @@ func TestHubBridgeSubagentProgress(t *testing.T) {
|
||||
|
||||
func TestPublishPublicNilBus(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb := newTestHub(nil, nil) // nil bus
|
||||
hb := newTestHub(nil, nil) // bus 为 nil
|
||||
c := newOwnerConn(hb, 32)
|
||||
// Must not panic and must not enqueue anything.
|
||||
// 不得 panic,也不得入队任何东西。
|
||||
c.publishPublic(wsChatSubject, map[string]any{"text": "hi"})
|
||||
}
|
||||
|
||||
@@ -1272,7 +1270,7 @@ func TestOriginCheckerSameHost(t *testing.T) {
|
||||
allowed := map[string]bool{"http://ok.example": true}
|
||||
checker := originChecker(allowed, false)
|
||||
|
||||
// Same host (exact) — production mode serving embedded frontend on the gateway port.
|
||||
// 同主机(精确匹配)—— 生产模式下在网关端口提供内嵌前端。
|
||||
req := httptest.NewRequest(http.MethodGet, "/ws/chat", nil)
|
||||
req.Host = "localhost:10942"
|
||||
req.Header.Set("Origin", "http://localhost:10942")
|
||||
@@ -1280,7 +1278,7 @@ func TestOriginCheckerSameHost(t *testing.T) {
|
||||
t.Fatal("same host origin should be allowed")
|
||||
}
|
||||
|
||||
// Loopback alias: host is 127.0.0.1 but client origin uses localhost.
|
||||
// 回环别名:host 是 127.0.0.1,但客户端 origin 用的是 localhost。
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/ws/chat", nil)
|
||||
req2.Host = "127.0.0.1:10942"
|
||||
req2.Header.Set("Origin", "http://localhost:10942")
|
||||
@@ -1288,7 +1286,7 @@ func TestOriginCheckerSameHost(t *testing.T) {
|
||||
t.Fatal("localhost<->127.0.0.1 loopback alias should be allowed")
|
||||
}
|
||||
|
||||
// Different host must still be rejected (not a same-origin, not whitelisted).
|
||||
// 不同主机仍必须被拒(既非同源,也不在白名单)。
|
||||
req3 := httptest.NewRequest(http.MethodGet, "/ws/chat", nil)
|
||||
req3.Host = "evil.example:10942"
|
||||
req3.Header.Set("Origin", "http://localhost:10942")
|
||||
@@ -1296,7 +1294,7 @@ func TestOriginCheckerSameHost(t *testing.T) {
|
||||
t.Fatal("cross-host origin should be rejected")
|
||||
}
|
||||
|
||||
// Same host, different port: still same-origin (same physical host), so allow.
|
||||
// 同主机、不同端口:仍是同源(同一物理主机),因此放行。
|
||||
req4 := httptest.NewRequest(http.MethodGet, "/ws/chat", nil)
|
||||
req4.Host = "localhost:8080"
|
||||
req4.Header.Set("Origin", "http://localhost:10942")
|
||||
@@ -1320,7 +1318,7 @@ func TestWSTokenExtraction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Start / ListenAndServe lifecycle -------------------------------------
|
||||
// --- Start / ListenAndServe 生命周期 ------------------------------------------
|
||||
|
||||
func TestStartAndShutdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -1344,7 +1342,7 @@ func TestStartAndShutdown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---------------------------------------------------------------
|
||||
// --- 辅助函数 ----------------------------------------------------------------
|
||||
|
||||
func doHandler(t *testing.T, req *http.Request) *http.Response {
|
||||
t.Helper()
|
||||
@@ -1396,11 +1394,11 @@ func newFakeConn(hb *hub, buf int) *wsConn {
|
||||
}
|
||||
}
|
||||
|
||||
// newOwnerConn is a fake connection carrying owner claims: the design/11 §7.1 frame
|
||||
// gate sends every content frame (message.*, thinking.*, tool_*, graph_update, mood,
|
||||
// persisted-message frames, …) to owner/admin only, so a hub unit test that reads
|
||||
// one has to connect as the role it is gated to. A nil-claims conn (newFakeConn) is
|
||||
// the fail-closed bare connection — always reachable by the state signals only.
|
||||
// newOwnerConn 是携带 owner claims 的假连接:design/11 §7.1 帧门把每个
|
||||
// 内容帧(message.*、thinking.*、tool_*、graph_update、mood、持久化消息
|
||||
// 帧,……)只发给 owner/admin,因此读取其中之一的 hub 单元测试必须以
|
||||
// 被放行的角色连接。claims 为 nil 的连接(newFakeConn)是 fail-closed
|
||||
// 的裸连接 —— 仅状态信号总能到达。
|
||||
func newOwnerConn(hb *hub, buf int) *wsConn {
|
||||
c := newFakeConn(hb, buf)
|
||||
c.claims = &auth.Claims{Role: auth.RoleOwner}
|
||||
@@ -1441,7 +1439,7 @@ func reqWithQuery(k, v string) *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/ws/chat?"+k+"="+v, nil)
|
||||
}
|
||||
|
||||
// --- RAG knowledge base ----------------------------------------------------
|
||||
// --- RAG 知识库 --------------------------------------------------------------
|
||||
|
||||
func TestAdminRagListsAndSearches(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -1451,7 +1449,7 @@ func TestAdminRagListsAndSearches(t *testing.T) {
|
||||
_ = srv
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// List only.
|
||||
// 仅列表。
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
@@ -1470,7 +1468,7 @@ func TestAdminRagListsAndSearches(t *testing.T) {
|
||||
t.Fatalf("hits = %+v, want omitted without query", list.Hits)
|
||||
}
|
||||
|
||||
// List + search via ?query=.
|
||||
// 列表 + 经 ?query= 检索。
|
||||
resp2, body2 := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag?query=rag", nil, tok)
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("search status = %d, want 200 (body=%s)", resp2.StatusCode, body2)
|
||||
@@ -1516,7 +1514,7 @@ func TestAdminRagEmptyWhenUnwired(t *testing.T) {
|
||||
_ = srv
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// List degrades to empty (never a 500).
|
||||
// 列表降级为空(绝不 500)。
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/rag", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
@@ -1531,7 +1529,7 @@ func TestAdminRagEmptyWhenUnwired(t *testing.T) {
|
||||
t.Fatalf("documents = %+v, want empty", list.Documents)
|
||||
}
|
||||
|
||||
// Query reports a clear 501 (backend not implemented).
|
||||
// 检索明确返回 501(后端未实现)。
|
||||
resp2, body2 := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/rag/query", map[string]any{"query": "rag"}, tok)
|
||||
if resp2.StatusCode != http.StatusNotImplemented {
|
||||
t.Fatalf("query status = %d, want 501 (body=%s)", resp2.StatusCode, body2)
|
||||
@@ -1542,8 +1540,8 @@ func TestAdminRagEmptyWhenUnwired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminRagDeleteUnsupportedStore501 asserts a store that cannot delete is
|
||||
// answered with an honest 501 rather than a fabricated success.
|
||||
// TestAdminRagDeleteUnsupportedStore501 断言无法删除的存储会被诚实地以
|
||||
// 501 应答,而不是伪造成功。
|
||||
func TestAdminRagDeleteUnsupportedStore501(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{RAG: &stubRag{err: knowledge.ErrWriteUnsupported}})
|
||||
|
||||
@@ -10,15 +10,15 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
)
|
||||
|
||||
// These tests cover the D3 realtime-sync contract: a successful message edit or
|
||||
// delete must reach the account's other browsers as a WS frame, not only the client
|
||||
// that issued the REST call (otherwise every other browser has to re-read to converge).
|
||||
// 这些测试覆盖 D3 实时同步契约:一次成功的消息编辑或删除必须以
|
||||
// WS 帧到达该账号的其他浏览器,而不仅发起 REST 调用的客户端
|
||||
// (否则其他每个浏览器都得重读才能收敛)。
|
||||
|
||||
// TestPatchMessageBroadcastsEdited covers ①: PATCH /api/v1/messages/{id} answers 200
|
||||
// and pushes exactly one `message_edited` frame scoped to the edited message's own
|
||||
// session, carrying the new content (and the mode/createdAt a client renders with).
|
||||
// The frame's data is the updated message DTO with thinking stripped, matching the
|
||||
// message_persisted contract; the REST reply keeps thinking (owner/admin-only read).
|
||||
// TestPatchMessageBroadcastsEdited 覆盖 ①:PATCH /api/v1/messages/{id}
|
||||
// 返回 200,并恰好推送一个 `message_edited` 帧,其作用域为被编辑消息
|
||||
// 自身的会话,携带新内容(以及客户端渲染所用的 mode/createdAt)。该帧
|
||||
// 的 data 是抹除了 thinking 的更新后消息 DTO,与 message_persisted
|
||||
// 契约一致;REST 回复保留 thinking(仅 owner/admin 可读)。
|
||||
func TestPatchMessageBroadcastsEdited(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -51,7 +51,7 @@ func TestPatchMessageBroadcastsEdited(t *testing.T) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
// The REST reply is the full DTO for the issuing client.
|
||||
// REST 回复是发给发起方客户端的完整 DTO。
|
||||
var reply historyMessage
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &reply); err != nil {
|
||||
t.Fatalf("decode reply: %v (body=%s)", err, body)
|
||||
@@ -93,10 +93,9 @@ func TestPatchMessageBroadcastsEdited(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteMessageBroadcastsDeleted covers ②: DELETE /api/v1/messages/{id} answers
|
||||
// 200, really removes the row, and pushes one `message_deleted` frame carrying the id
|
||||
// plus the owning session (read before the row disappears) so a remote browser can
|
||||
// drop it without re-reading.
|
||||
// TestDeleteMessageBroadcastsDeleted 覆盖 ②:DELETE /api/v1/messages/{id}
|
||||
// 返回 200,真正删除该行,并推送一个 `message_deleted` 帧,携带 id 以及
|
||||
// 所属会话(在该行消失前读出),使远端浏览器无需重读即可移除它。
|
||||
func TestDeleteMessageBroadcastsDeleted(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -133,7 +132,7 @@ func TestDeleteMessageBroadcastsDeleted(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The broadcast must not claim a delete that did not happen.
|
||||
// 广播不得宣称一次并未发生的删除。
|
||||
remaining, err := st.List(ctx, sub, 0, 50, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list after delete: %v", err)
|
||||
@@ -145,9 +144,9 @@ func TestDeleteMessageBroadcastsDeleted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteMessageUnknownIDBroadcastsNothing pins the failure side: a 404 delete
|
||||
// (unknown id) must not push a `message_deleted` frame, or a client would drop a
|
||||
// message that still exists.
|
||||
// TestDeleteMessageUnknownIDBroadcastsNothing 钉住失败侧:404 的删除
|
||||
// (未知 id)不得推送 `message_deleted` 帧,否则客户端会丢掉一条仍然
|
||||
// 存在的消息。
|
||||
func TestDeleteMessageUnknownIDBroadcastsNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, ts, jwt, _ := newHistoryEnv(t)
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// moodRunner is a TurnRunner that settles one turn with a fixed result, so the
|
||||
// test can assert the mood the loop reported is what gets persisted.
|
||||
// moodRunner 是一个 TurnRunner,以固定结果结算一轮,使测试能断言
|
||||
// 被持久化的正是 loop 报告的心情。
|
||||
type moodRunner struct {
|
||||
mood string
|
||||
}
|
||||
@@ -21,12 +21,11 @@ func (m moodRunner) Run(_ context.Context, _ string, _ []model.Message, _ loop.S
|
||||
return &loop.Result{Text: "在的呀♪", Steps: 1, StopReason: "stop", Mood: m.mood}, nil
|
||||
}
|
||||
|
||||
// TestOrchestratorPersistsTurnMood locks the mood column end to end on the Web
|
||||
// path: the mood the loop's turn-boundary hook reported lands on the persisted
|
||||
// assistant message (design/16 §3.3 `mode/mood/thinking`), so the DTO,
|
||||
// `message_persisted` and history replay carry real data instead of an always
|
||||
// empty mood. The user message keeps an empty mood (design/16 §3.3 assigns the
|
||||
// mood to the assistant reply).
|
||||
// TestOrchestratorPersistsTurnMood 在 Web 路径上端到端锁住 mood 列:
|
||||
// loop 轮次边界钩子报告的心情落到持久化的助手消息上
|
||||
// (design/16 §3.3 `mode/mood/thinking`),因此 DTO、`message_persisted`
|
||||
// 与历史回放携带真实数据,而不是永远为空的心情。用户消息保持空
|
||||
// 心情(design/16 §3.3 把心情分配给助手回复)。
|
||||
func TestOrchestratorPersistsTurnMood(t *testing.T) {
|
||||
t.Parallel()
|
||||
hist := &fakeHistory{}
|
||||
@@ -56,9 +55,9 @@ func TestOrchestratorPersistsTurnMood(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrchestratorPersistsEmptyMoodWhenUntracked locks the honest degrade: with no
|
||||
// emotion tracking (no hook wired, so Result.Mood is empty) the stored mood stays
|
||||
// empty rather than being fabricated.
|
||||
// TestOrchestratorPersistsEmptyMoodWhenUntracked 锁住诚实的降级:没有
|
||||
// 情绪跟踪时(未接钩子,因此 Result.Mood 为空)存储的心情保持为空,
|
||||
// 而不是被编造。
|
||||
func TestOrchestratorPersistsEmptyMoodWhenUntracked(t *testing.T) {
|
||||
t.Parallel()
|
||||
hist := &fakeHistory{}
|
||||
@@ -79,9 +78,9 @@ func TestOrchestratorPersistsEmptyMoodWhenUntracked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerBroadcastMood locks the `mood` frame the frontend's mood handler reads
|
||||
// (design/07 §3): the type is `mood`, data.mood is the label, and the persona id /
|
||||
// affection ride along as extra detail.
|
||||
// TestServerBroadcastMood 锁住前端 mood 处理器读取的 `mood` 帧
|
||||
// (design/07 §3):类型是 `mood`,data.mood 是标签,角色 id / 好感度
|
||||
// 作为额外细节一同携带。
|
||||
func TestServerBroadcastMood(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newEnv(t, nil, false)
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
// Package gateway implements the Cyrene v2 HTTP gateway: the only socket the
|
||||
// single binary opens. It serves the versioned REST API (/api/v1), the realtime
|
||||
// WebSocket hub (/ws/chat) and the RBAC-protected admin API (/api/v1/admin/*)
|
||||
// all on one port (:8080).
|
||||
// Package gateway 实现 Cyrene v2 的 HTTP 网关:单二进制唯一打开的套接字。
|
||||
// 它在同一个端口(:8080)上提供版本化 REST API(/api/v1)、实时
|
||||
// WebSocket hub(/ws/chat)以及受 RBAC 保护的管理 API(/api/v1/admin/*)。
|
||||
//
|
||||
// It is deliberately lean. All backend capability flows through injected
|
||||
// dependencies (construction-time dependency injection) and the in-process bus,
|
||||
// never through cross-process IPC, so the package never opens a listener of its
|
||||
// own beyond the gateway port.
|
||||
// 它是刻意精简的。所有后端能力都经由注入的依赖(构造期依赖注入)和
|
||||
// 进程内 bus 流动,绝不跨进程 IPC,因此本包除网关端口外永不自行
|
||||
// 打开监听器。
|
||||
//
|
||||
// Client contract (DevDocs/02-development-constraints.md §九, §十一):
|
||||
// - REST responses are {data} on success and {error:{code,message}} on error.
|
||||
// - Authentication is JWT bearer (internal/auth); authorization is the
|
||||
// owner>admin>viewer RBAC hierarchy (internal/auth).
|
||||
// - Admin writes are audited (who/what/when/result).
|
||||
// - Realtime events follow DevDocs/design/07 §3 (WS protocol).
|
||||
// 客户端契约(DevDocs/02-development-constraints.md §九、§十一):
|
||||
// - REST 成功响应为 {data},错误响应为 {error:{code,message}}。
|
||||
// - 认证是 JWT bearer(internal/auth);授权是 owner>admin>viewer 的
|
||||
// RBAC 层级(internal/auth)。
|
||||
// - 管理写操作有审计(谁/做了什么/何时/结果)。
|
||||
// - 实时事件遵循 DevDocs/design/07 §3(WS 协议)。
|
||||
//
|
||||
// Layout: contracts.go declares the injected dependency interfaces, deps.go the
|
||||
// Deps bundle, server.go the Server lifecycle (construct / serve / close),
|
||||
// routes.go the route table + outer middleware, broadcast.go the outbound
|
||||
// realtime pushes.
|
||||
// 文件布局:contracts.go 声明注入的依赖接口,deps.go 声明 Deps 组合,
|
||||
// server.go 是 Server 生命周期(构造 / 服务 / 关闭),routes.go 是路由表
|
||||
// 加外层中间件,broadcast.go 是下行实时推送。
|
||||
package gateway
|
||||
|
||||
import (
|
||||
@@ -40,11 +37,11 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// source is the log provenance tag for every record this package emits.
|
||||
// source 是本包每条日志记录的来源标签。
|
||||
const source = "gateway"
|
||||
|
||||
// Server is the gateway. It owns a ServeMux aggregating REST + WS + admin and a
|
||||
// WebSocket hub. It is safe for concurrent use once constructed.
|
||||
// Server 是网关本体。它持有聚合 REST + WS + admin 的 ServeMux 与一个
|
||||
// WebSocket hub。构造完成后可安全并发使用。
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
auth Authenticator
|
||||
@@ -68,32 +65,30 @@ type Server struct {
|
||||
streaming bool
|
||||
frontend fs.FS
|
||||
dbPath string
|
||||
// dataDir is the resolved data directory backing the upload store
|
||||
// (<dataDir>/uploads). Injected by the composition root; cfg.DataDir is the
|
||||
// fallback. See Deps.DataDir.
|
||||
// dataDir 是支撑上传存储的已解析数据目录(<dataDir>/uploads)。
|
||||
// 由组合根注入;cfg.DataDir 是回退值。见 Deps.DataDir。
|
||||
dataDir string
|
||||
// llm is the hot-swappable provider stack (models.json). When nil the
|
||||
// admin /llm endpoints fall back to the env config. It is optional so tests
|
||||
// that do not wire it keep working.
|
||||
// llm 是可热切换的提供方栈(models.json)。为 nil 时 admin 的 /llm
|
||||
// 端点回退到环境变量配置。它是可选的,以便未接它的测试仍能工作。
|
||||
llm *model.RuntimeLLM
|
||||
channels ChannelConfigStore
|
||||
// plugins is the runtime-installable external tool provider manager. See
|
||||
// Deps.Plugins.
|
||||
// plugins 是可在运行时安装的外部工具提供方管理器。见
|
||||
// Deps.Plugins。
|
||||
plugins *pluginmgr.Manager
|
||||
// ownerRegistrar stamps a scan-login bound owner id into the owner whitelist
|
||||
// (best-effort). See Deps.OwnerRegistrar.
|
||||
// ownerRegistrar 把扫码登录绑定的 owner id 写入 owner 白名单
|
||||
// (尽力而为)。见 Deps.OwnerRegistrar。
|
||||
ownerRegistrar OwnerRegistrar
|
||||
webhooks map[string]http.Handler
|
||||
// envResolver resolves the per-turn loop environment (its role) for a session.
|
||||
// See persona_env.go / design/22 §4.5.
|
||||
// envResolver 解析会话每轮的 loop 环境(其角色)。见
|
||||
// persona_env.go / design/22 §4.5。
|
||||
envResolver turnEnvResolver
|
||||
log *slog.Logger
|
||||
hub *hub
|
||||
}
|
||||
|
||||
// New validates cfg and deps and returns a ready Server. Auth is required; the
|
||||
// WebSocket hub subscribes to deps.Bus immediately so events published before
|
||||
// ListenAndServe are still relayed to clients.
|
||||
// New 校验 cfg 与 deps 并返回就绪的 Server。Auth 必填;WebSocket hub
|
||||
// 会立即订阅 deps.Bus,因此 ListenAndServe 之前发布的事件仍会转发给
|
||||
// 客户端。
|
||||
func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("gateway: nil config")
|
||||
@@ -131,9 +126,9 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
dataDir: deps.DataDir,
|
||||
log: sourcedLog(deps.Log),
|
||||
}
|
||||
// One role resolver shared by the Server handlers and the hub, so every
|
||||
// owner-facing entry point (REST, WS, voice) resolves the session's role the
|
||||
// same way (design/22 §4.5) instead of each hardcoding loop.OwnerEnv().
|
||||
// 一个角色解析器由 Server 各处理器与 hub 共用,使每个面向 owner
|
||||
// 的入口(REST、WS、语音)以同样方式解析会话角色(design/22 §4.5),
|
||||
// 而不是各自硬编码 loop.OwnerEnv()。
|
||||
s.envResolver = turnEnvResolver{history: deps.History, personas: deps.Personas, log: s.log}
|
||||
s.hub = newHub(s)
|
||||
s.hub.orchestrator = newOrchestrator(s)
|
||||
@@ -141,16 +136,16 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Start launches the gateway in a goroutine, returning a channel that carries the
|
||||
// ListenAndServe result. The server runs until ctx is canceled.
|
||||
// Start 在 goroutine 中启动网关,返回携带 ListenAndServe 结果的
|
||||
// channel。服务器运行至 ctx 被取消。
|
||||
func (s *Server) Start(ctx context.Context) (<-chan error, error) {
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- s.ListenAndServe(ctx) }()
|
||||
return errCh, nil
|
||||
}
|
||||
|
||||
// ListenAndServe binds the configured host:port (default :8080) and blocks until
|
||||
// ctx is canceled, then gracefully drains in-flight requests.
|
||||
// ListenAndServe 绑定配置的 host:port(默认 :8080)并阻塞至 ctx 被
|
||||
// 取消,随后优雅地排空在途请求。
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -178,17 +173,15 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close frees the hub's bus subscription and closes live connections.
|
||||
// Close 释放 hub 的 bus 订阅并关闭存活连接。
|
||||
func (s *Server) Close() { s.hub.close() }
|
||||
|
||||
// newOrchestrator builds the session-message orchestrator (design/15) bound to
|
||||
// this server's hub. The TurnRunner shares the hub's runTurn (so a turn always
|
||||
// emits the same stream contract), and the StateSink fans per-session state
|
||||
// transitions out through the hub broadcast for the frontend — those transitions
|
||||
// are the design/11 §7.1 「状态机信号」, so every authenticated connection may follow
|
||||
// them. It is wrapped in a
|
||||
// function so the composition root can later override the injection (an explicit
|
||||
// wiring point rather than a hard-coded construct).
|
||||
// newOrchestrator 构建绑定到本 server 的 hub 的会话消息 orchestrator
|
||||
// (design/15)。TurnRunner 复用 hub 的 runTurn(因此一轮总是发出相同的
|
||||
// 流契约),StateSink 则把每会话状态迁移经由 hub 广播扇出给前端 ——
|
||||
// 这些迁移属于 design/11 §7.1 的「状态机信号」,因此每个已认证连接都
|
||||
// 可以跟随它们。它被包在函数里,以便组合根之后能覆盖注入(一个显式
|
||||
// 的接线点,而不是硬编码的构造)。
|
||||
func newOrchestrator(s *Server) *Orchestrator {
|
||||
return NewOrchestrator(OrchestratorOptions{
|
||||
Run: func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
|
||||
@@ -203,8 +196,8 @@ func newOrchestrator(s *Server) *Orchestrator {
|
||||
})
|
||||
}
|
||||
|
||||
// sourcedLog returns a logger tagged with source "gateway", substituting a
|
||||
// discard logger for nil so Server can always log safely.
|
||||
// sourcedLog 返回带 source "gateway" 标签的 logger,nil 时替换为丢弃型
|
||||
// logger,使 Server 总能安全地打日志。
|
||||
func sourcedLog(l *slog.Logger) *slog.Logger {
|
||||
if l == nil {
|
||||
l = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
@@ -11,39 +11,36 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
)
|
||||
|
||||
// This file owns the D4 read-cursor surface (Android collaboration B3): the
|
||||
// POST /api/v1/sessions/{id}/read endpoint that advances a session's read cursor,
|
||||
// plus the `session_read` realtime frame it broadcasts so the account's other
|
||||
// browsers clear the unread badge without a re-read. The cursor itself lives in
|
||||
// chat_sessions.read_at (internal/runtime/history); GET /sessions derives the
|
||||
// unreadCount from it (history.go). The cursor is session-scoped and single-owner:
|
||||
// one account, one cursor per session — reading on any one end marks the session
|
||||
// read for every end.
|
||||
// 本文件归属 D4 已读游标面(Android 协作 B3):推进会话已读游标的
|
||||
// POST /api/v1/sessions/{id}/read 端点,以及它为清除账号其他浏览器
|
||||
// 未读徽标(无需重读)而广播的 `session_read` 实时帧。游标本身存于
|
||||
// chat_sessions.read_at(internal/runtime/history);GET /sessions 由它
|
||||
// 推导 unreadCount(history.go)。游标是会话级、单所有者的:
|
||||
// 一个账号每个会话一个游标 —— 在任意一端阅读即为所有端标记该
|
||||
// 会话已读。
|
||||
|
||||
// wsSessionRead is the WS event type for a read-cursor advance.
|
||||
// wsSessionRead 是已读游标推进的 WS 事件类型。
|
||||
const wsSessionRead = "session_read"
|
||||
|
||||
// sessionReadRequest is the POST /api/v1/sessions/{id}/read body. Before is a
|
||||
// pointer so an absent field (an empty body) is distinguishable from an explicit
|
||||
// value: absent means "now" — the client marks everything it has seen read.
|
||||
// sessionReadRequest 是 POST /api/v1/sessions/{id}/read 的请求体。
|
||||
// Before 是指针,以便把字段缺失(空请求体)与显式取值区分开:
|
||||
// 缺失表示「现在」—— 客户端把它已看到的一切都标记为已读。
|
||||
type sessionReadRequest struct {
|
||||
Before *int64 `json:"before"`
|
||||
}
|
||||
|
||||
// handleMarkSessionRead advances a session's read cursor and answers
|
||||
// {data:{sessionId, readAt}} with the resulting cursor in Unix milliseconds.
|
||||
// It is owner/admin-gated like the other history writes (writeAuth).
|
||||
// handleMarkSessionRead 推进会话的已读游标,并以 Unix 毫秒返回结果
|
||||
// 游标 {data:{sessionId, readAt}}。与其他历史写入一样走 owner/admin
|
||||
// 门(writeAuth)。
|
||||
//
|
||||
// body {before?} is an optional millisecond timestamp bounding what is marked
|
||||
// read — messages with created_at <= readAt become read. Absent (or an empty body)
|
||||
// means now. A present non-positive or non-numeric before answers 400: the cursor
|
||||
// is a real millisecond timestamp, and "unread by a negative cursor" is not a
|
||||
// meaningful client request. An unknown session id answers 404; a missing
|
||||
// HistoryStore answers 501.
|
||||
// 请求体 {before?} 是可选毫秒时间戳,界定标记已读的范围 ——
|
||||
// created_at <= readAt 的消息变为已读。缺失(或空请求体)表示现在。
|
||||
// 已给出但非正数或非数值的 before 返回 400:游标是真实的毫秒
|
||||
// 时间戳,「负数游标造成的未读」不是有意义的客户端请求。未知
|
||||
// 会话 id 返回 404;HistoryStore 缺失返回 501。
|
||||
//
|
||||
// On success the new cursor is broadcast as a `session_read` frame so the account's
|
||||
// other browsers clear the badge too (reading on one end = read everywhere). A
|
||||
// failure path broadcasts nothing.
|
||||
// 成功时新游标作为 `session_read` 帧广播,使该账号其他浏览器也清除
|
||||
// 徽标(一端阅读 = 处处已读)。失败路径不广播任何东西。
|
||||
func (s *Server) handleMarkSessionRead(w http.ResponseWriter, r *http.Request) {
|
||||
if s.history == nil {
|
||||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
|
||||
@@ -81,15 +78,13 @@ func (s *Server) handleMarkSessionRead(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sessionId": id, "readAt": readAt})
|
||||
}
|
||||
|
||||
// broadcastSessionRead fans a read-cursor advance out to every client as a
|
||||
// `session_read` frame carrying the session's new cursor, so the same account's
|
||||
// other ends clear the session's unread badge without a re-read. It is one of the
|
||||
// design/11 §7.1 「状态机信号」 (it reports a cursor, never a message body), so the
|
||||
// frame gate keeps it on the all-connections path. Like the other history frames it
|
||||
// relies on the hub having no per-session
|
||||
// subscription), and the client filters it by sessionId; the cursor's single source
|
||||
// of truth stays chat_sessions.read_at, so a client that missed the frame converges
|
||||
// on its next GET /sessions.
|
||||
// broadcastSessionRead 把一次已读游标推进作为 `session_read` 帧扇出到
|
||||
// 每个客户端,携带会话的新游标,使同一账号的其他端无需重读即可
|
||||
// 清除该会话的未读徽标。它属于 design/11 §7.1 的「状态机信号」
|
||||
// (只报告游标,绝不报告消息正文),因此帧门让它留在全连接路径上。
|
||||
// 与其他历史帧一样,它依赖 hub 没有按会话订阅这一点,由客户端按
|
||||
// sessionId 过滤;游标的唯一事实来源仍是 chat_sessions.read_at,
|
||||
// 因此错过该帧的客户端会在下次 GET /sessions 时重新收敛。
|
||||
func (h *hub) broadcastSessionRead(sessionID string, readAt int64) {
|
||||
h.broadcast(wsEvent{
|
||||
Type: wsSessionRead,
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
package gateway
|
||||
|
||||
// This file owns the outbound WS frame role gate (design/11 §7.1 「WS 下行帧的
|
||||
// 角色门」, decided by the owner on 2026-10-03). It is the ONE place that decides
|
||||
// which connections a downstream frame may reach:
|
||||
// 本文件是 WS 下行帧角色门的唯一归属地(design/11 §7.1「WS 下行帧的
|
||||
// 角色门」,主人于 2026-10-03 决策)。它是判定一个下行帧可以触达哪些
|
||||
// 连接的唯一位置:
|
||||
//
|
||||
// - a frame whose type is listed in frameReachesAll is a 「状态机信号」: it
|
||||
// carries no content, so every authenticated connection receives it;
|
||||
// - EVERY other frame type — reply text (message.*), thinking, tool calls and
|
||||
// results, persisted/edited/deleted messages, graph updates, sub-agent and
|
||||
// task traffic, private persona/device state — goes to owner + admin only.
|
||||
// - 类型列在 frameReachesAll 中的帧属于「状态机信号」:不含内容,
|
||||
// 因此每个已认证连接都能收到;
|
||||
// - 其余所有帧类型 —— 回复正文(message.*)、思考、工具调用与
|
||||
// 结果、持久化/编辑/删除的消息、图谱更新、子代理与任务
|
||||
// 流量、角色私有状态/设备状态 —— 只发 owner + admin。
|
||||
//
|
||||
// The classification is fail-closed: a new frame type answers false until someone
|
||||
// deliberately registers it here AND in design/11 §7.1, so forgetting to classify
|
||||
// a type can never leak content. No call site decides visibility for itself (a
|
||||
// per-call-site flag is exactly how one missed spot becomes a leak), which is why
|
||||
// hub.broadcast looks the class up from the frame it was handed.
|
||||
// 分类是 fail-closed 的:新帧类型在有人把它显式登记到此处、并且在
|
||||
// design/11 §7.1 登记之前一律返回 false,因此漏分类某个类型
|
||||
// 永远不会泄露内容。没有任何调用点自行决定可见性(按调用点传
|
||||
// 标志正是「漏一处即成泄露」的来源),这也是 hub.broadcast
|
||||
// 从收到的帧反查类别的原因。
|
||||
//
|
||||
// This gate decides who is PUSHED to. Data visibility stays with the REST gates:
|
||||
// a viewer still reads its 12 read-only monitoring endpoints through viewerAuth
|
||||
// (design/11 §7.1 「语义澄清」).
|
||||
// 本门只决定向谁 PUSH。数据可见性仍归 REST 门:
|
||||
// viewer 依然只能通过 viewerAuth 读取它的 12 个只读监控端点
|
||||
// (design/11 §7.1「语义澄清」)。
|
||||
|
||||
// frameReachesAll reports whether a downstream frame type may be delivered to
|
||||
// every authenticated connection. Only the 「状态机信号」 of the design/11 §7.1
|
||||
// whitelist qualify: session_busy / pending_ack / pending_drain / session_idle /
|
||||
// session_interrupted / pending_full / session_read. Each one only reports that a
|
||||
// session changed busy state, so a read-only monitor can follow the conversation's
|
||||
// activity without receiving the conversation itself.
|
||||
// frameReachesAll 报告某个下行帧类型是否可投递给每个已认证
|
||||
// 连接。只有 design/11 §7.1 白名单中的「状态机信号」符合条件:
|
||||
// session_busy / pending_ack / pending_drain / session_idle /
|
||||
// session_interrupted / pending_full / session_read。它们只报告
|
||||
// 会话忙闲状态发生了变化,因此只读监控端可以跟随对话的
|
||||
// 活跃度,而不必接收对话本身。
|
||||
//
|
||||
// Widening this list is a contract change: register the reason (「不含内容」) in
|
||||
// design/11 §7.1 and mirror it in ws_frame_scope_test.go first.
|
||||
// 扩大这个名单属于契约变更:先在 design/11 §7.1 登记理由
|
||||
// (「不含内容」),并在 ws_frame_scope_test.go 中同步。
|
||||
func frameReachesAll(typ string) bool {
|
||||
switch typ {
|
||||
case EventSessionBusy,
|
||||
|
||||
@@ -14,45 +14,42 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// These tests lock the design/11 §7.1 frame gate: a viewer connection may follow
|
||||
// the session state machine but must never receive content, while owner and admin
|
||||
// keep both classes. frameScopeCases is the design/11 §7.1 table in code — every
|
||||
// frame type named there, published on the subject that really produces it — so a
|
||||
// classification mistake (a content frame made public, or a state signal made
|
||||
// private) fails here instead of leaking silently.
|
||||
// 这些测试锁住 design/11 §7.1 的帧门:viewer 连接可以跟随会话状态机,
|
||||
// 但绝不可收到内容,而 owner 与 admin 两类都保留。frameScopeCases 是
|
||||
// design/11 §7.1 表在代码中的体现 —— 表中点名的每种帧类型,都发布在
|
||||
// 真正产生它的主题上 —— 因此分类错误(内容帧被公开,或状态信号被
|
||||
// 私有化)会在这里失败,而不是静默泄露。
|
||||
|
||||
// frameScopeCase is one row of the design/11 §7.1 table.
|
||||
// frameScopeCase 是 design/11 §7.1 表的一行。
|
||||
type frameScopeCase struct {
|
||||
typ string // downstream frame type
|
||||
subject string // bus subject that produces it
|
||||
payload any // payload published on that subject
|
||||
public bool // 「状态机信号」: no content → every authenticated connection
|
||||
typ string // 下行帧类型
|
||||
subject string // 产生它的 bus 主题
|
||||
payload any // 发布在该主题上的载荷
|
||||
public bool // 「状态机信号」:不含内容 → 每个已认证连接
|
||||
}
|
||||
|
||||
// scopeSentinel is published last: the bus runs one worker per subscription
|
||||
// sequentially, so a connection that has seen the sentinel has already been
|
||||
// offered every frame published before it. That turns "the viewer received nothing
|
||||
// else" into an exact count instead of a guessed idle window.
|
||||
// scopeSentinel 最后发布:bus 每个订阅跑一个 worker 且按序处理,因此
|
||||
// 已经看到哨兵的连接必定已经被投递过它之前发布的每一个帧。这把
|
||||
// 「viewer 没有收到别的」从猜测的空闲窗口变成精确计数。
|
||||
const scopeSentinel = EventSessionIdle
|
||||
|
||||
// The batch pacing used by publishFrameScopeCases, kept well under the hub's
|
||||
// wsSendBuffer depth so a burst never trips the slow-client drop.
|
||||
// publishFrameScopeCases 使用的批次节流,远低于 hub 的 wsSendBuffer
|
||||
// 深度,因此一次突发永不触发慢客户端丢弃。
|
||||
const (
|
||||
scopePaceFrames = 4
|
||||
scopePacePause = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
// scopeRead is the outcome of one connection's reader goroutine.
|
||||
// scopeRead 是一个连接读取 goroutine 的结果。
|
||||
type scopeRead struct {
|
||||
role auth.Role
|
||||
types []string
|
||||
err error
|
||||
}
|
||||
|
||||
// frameScopeCases returns the design/11 §7.1 table. Relayed frames use their real
|
||||
// ws.<type> subject; stream frames publish the real model.StreamEvent the loop
|
||||
// emits on ws.stream, so the test drives the production path
|
||||
// bus → relay → broadcast instead of a hand-built frame.
|
||||
// frameScopeCases 返回 design/11 §7.1 表。转发帧使用它们真实的 ws.<type>
|
||||
// 主题;流帧发布 loop 在 ws.stream 上发出的真实 model.StreamEvent,因此
|
||||
// 测试驱动的是生产路径 bus → relay → broadcast,而不是手工构造的帧。
|
||||
func frameScopeCases() []frameScopeCase {
|
||||
relayed := func(typ string, public bool) frameScopeCase {
|
||||
return frameScopeCase{typ: typ, subject: wsPrefix + typ, payload: map[string]any{"sessionId": "web"}, public: public}
|
||||
@@ -113,12 +110,11 @@ func frameScopeCases() []frameScopeCase {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWSFrameScopePerRole drives the whole design/11 §7.1 table through real
|
||||
// WebSocket connections with real role tokens: owner and admin receive every frame
|
||||
// of the table, while the viewer receives exactly the whitelisted state signals, in
|
||||
// publish order. The viewer assertions the design calls out by name (message.delta /
|
||||
// thinking.delta / message_persisted / graph_update / mood denied, session_idle
|
||||
// delivered) are re-stated explicitly so the contract is readable in the test.
|
||||
// TestWSFrameScopePerRole 用真实角色 token 经真实 WebSocket 连接驱动
|
||||
// 整张 design/11 §7.1 表:owner 与 admin 收到表中的每一帧,而 viewer
|
||||
// 恰好收到白名单状态信号,且按发布顺序。设计点名的 viewer 断言
|
||||
// (message.delta / thinking.delta / message_persisted / graph_update /
|
||||
// mood 被拒,session_idle 被投递)在此显式复述,使契约在测试中可读。
|
||||
func TestWSFrameScopePerRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := frameScopeCases()
|
||||
@@ -140,9 +136,9 @@ func TestWSFrameScopePerRole(t *testing.T) {
|
||||
auth.RoleViewer: len(public),
|
||||
}
|
||||
|
||||
// The clients drain on their own goroutines while the batch is published: the
|
||||
// hub drops frames for a client whose send buffer is full (its slow-client
|
||||
// policy), so a client that only started reading afterwards would lose the tail.
|
||||
// 各客户端在批次发布期间于自己的 goroutine 上排空:hub 会丢弃
|
||||
// 发送缓冲已满的客户端的帧(其慢客户端策略),因此之后才开始
|
||||
// 读取的客户端会丢掉尾部。
|
||||
results := make(chan scopeRead, len(conns))
|
||||
for role, want := range wants {
|
||||
role, want := role, want
|
||||
@@ -177,11 +173,10 @@ func TestWSFrameScopePerRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHubBroadcastScopeMatrix is the per-frame-type × per-role matrix, driven
|
||||
// directly through the delivery entry so every row of the table is asserted in
|
||||
// isolation (a leaked frame would otherwise be hard to attribute). claims are set
|
||||
// explicitly per role, so a default-deny regression shows up as the viewer losing
|
||||
// the frame it is entitled to.
|
||||
// TestHubBroadcastScopeMatrix 是「每种帧类型 × 每种角色」矩阵,直接经
|
||||
// 投递入口驱动,使表中每一行都被孤立断言(否则泄露的帧难以归因)。
|
||||
// claims 按角色显式设置,因此默认拒绝的回归会表现为 viewer 丢掉它
|
||||
// 本应收到的帧。
|
||||
func TestHubBroadcastScopeMatrix(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb := newTestHub(nil, nil)
|
||||
@@ -217,11 +212,10 @@ func TestHubBroadcastScopeMatrix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFrameScopeAllowsExactlyTheStateSignals pins the two properties that keep the
|
||||
// gate from drifting: an unclassified frame type is never public (so a frame added
|
||||
// by a later milestone cannot leak by omission), and the public set is exactly the
|
||||
// state signals design/11 §7.1 registers — widening it means editing the design, the
|
||||
// gate and this table together.
|
||||
// TestFrameScopeAllowsExactlyTheStateSignals 钉住两条使帧门不漂移的
|
||||
// 性质:未分类的帧类型永不是公开的(因此后续里程碑新增的帧不会因
|
||||
// 漏分类而泄露),且公开集合恰好是 design/11 §7.1 登记的状态信号
|
||||
// —— 扩大它意味着要同时改设计、帧门和这张表。
|
||||
func TestFrameScopeAllowsExactlyTheStateSignals(t *testing.T) {
|
||||
t.Parallel()
|
||||
if frameReachesAll("frame_type_nobody_classified") {
|
||||
@@ -250,8 +244,7 @@ func TestFrameScopeAllowsExactlyTheStateSignals(t *testing.T) {
|
||||
assertFrameTypes(t, "design/11 §7.1 whitelist", public, want)
|
||||
}
|
||||
|
||||
// frameTypes returns the table's frame types in table order (all of them, or only
|
||||
// the public ones).
|
||||
// frameTypes 按表顺序返回表中的帧类型(全部,或仅公开的那些)。
|
||||
func frameTypes(cases []frameScopeCase, publicOnly bool) []string {
|
||||
out := make([]string, 0, len(cases))
|
||||
for _, tc := range cases {
|
||||
@@ -263,10 +256,10 @@ func frameTypes(cases []frameScopeCase, publicOnly bool) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// publishFrameScopeCases publishes every row on its production subject, followed by
|
||||
// the sentinel that closes the batch. The batch is paced: a client whose send buffer
|
||||
// is full has its frames dropped (the hub's documented slow-client policy), so a
|
||||
// 37-frame burst would exercise the drop policy rather than the gate.
|
||||
// publishFrameScopeCases 在每行各自的生产主题上发布它,随后发布关闭
|
||||
// 批次的哨兵。批次是节流的:发送缓冲已满的客户端其帧会被丢弃
|
||||
// (hub 有文档的慢客户端策略),因此 37 帧的突发放送会去考验丢弃
|
||||
// 策略而不是帧门。
|
||||
func publishFrameScopeCases(t *testing.T, env *testEnv, cases []frameScopeCase) {
|
||||
t.Helper()
|
||||
for i, tc := range cases {
|
||||
@@ -282,9 +275,9 @@ func publishFrameScopeCases(t *testing.T, env *testEnv, cases []frameScopeCase)
|
||||
}
|
||||
}
|
||||
|
||||
// readFrameTypes reads exactly want frames and returns their types in arrival order.
|
||||
// It reports an error rather than failing the test so it can run on the reader
|
||||
// goroutines that drain each client while the batch is published.
|
||||
// readFrameTypes 恰好读取 want 个帧并按到达顺序返回其类型。它返回错误
|
||||
// 而不是让测试失败,以便能跑在批次发布期间排空各客户端的读取
|
||||
// goroutine 上。
|
||||
func readFrameTypes(conn *websocket.Conn, want int) ([]string, error) {
|
||||
got := make([]string, 0, want)
|
||||
for len(got) < want {
|
||||
@@ -302,7 +295,7 @@ func readFrameTypes(conn *websocket.Conn, want int) ([]string, error) {
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// assertFrameTypes compares the received frame types to the expected sequence.
|
||||
// assertFrameTypes 比较收到的帧类型与期望序列。
|
||||
func assertFrameTypes(t *testing.T, what string, got, want []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
@@ -315,7 +308,7 @@ func assertFrameTypes(t *testing.T, what string, got, want []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// containsFrameType reports whether typ is among the received frame types.
|
||||
// containsFrameType 报告 typ 是否在收到的帧类型之中。
|
||||
func containsFrameType(types []string, typ string) bool {
|
||||
for _, got := range types {
|
||||
if got == typ {
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
package gateway
|
||||
|
||||
// wsMessageEdited / wsMessageDeleted are the downstream event types of the D3
|
||||
// history writes: a message's content was edited, or a message was removed. Both
|
||||
// reuse the wsEvent envelope and the same fan-out as `message_persisted` — and,
|
||||
// like it and every other content frame, they are owner/admin only under the
|
||||
// design/11 §7.1 frame gate. The hub has no per-session subscription, so the frame
|
||||
// carries SessionID and a client filters it by its active session, exactly as it
|
||||
// does for message_persisted.
|
||||
// wsMessageEdited / wsMessageDeleted 是 D3 历史写入的下行事件类型:
|
||||
// 消息内容被编辑,或消息被删除。二者复用 wsEvent 信封与
|
||||
// `message_persisted` 相同的扇出 —— 并且与它以及其他所有内容帧
|
||||
// 一样,在 design/11 §7.1 帧门下只发 owner/admin。hub 没有按会话的
|
||||
// 订阅,因此帧携带 SessionID,由客户端按其活跃会话过滤,与它对
|
||||
// message_persisted 的处理完全一致。
|
||||
const (
|
||||
wsMessageEdited = "message_edited"
|
||||
wsMessageDeleted = "message_deleted"
|
||||
)
|
||||
|
||||
// broadcastMessageEdited fans a content edit out to the owner/admin connections as
|
||||
// a `message_edited` frame whose data is the updated message DTO. The frame is a
|
||||
// render-only sync: the edited content's single source of truth stays the history
|
||||
// store, so a client that missed the frame converges again on its next read. The
|
||||
// caller strips Thinking to match the message_persisted/replay contract.
|
||||
// broadcastMessageEdited 把一次内容编辑作为 `message_edited` 帧扇出到
|
||||
// owner/admin 连接,其 data 是更新后的消息 DTO。该帧只用于渲染同步:
|
||||
// 编辑后内容的唯一事实来源仍是 history store,因此错过该帧的客户端
|
||||
// 会在下次读取时重新收敛。调用方会抹除 Thinking,以匹配
|
||||
// message_persisted/回放契约。
|
||||
func (h *hub) broadcastMessageEdited(msg historyMessage) {
|
||||
h.broadcast(wsEvent{Type: wsMessageEdited, SessionID: msg.SessionID, Data: msg})
|
||||
}
|
||||
|
||||
// broadcastMessageDeleted fans a removal out to the owner/admin connections as a
|
||||
// `message_deleted` frame carrying the removed id (plus the owning sessionId for
|
||||
// client-side session filtering), so every browser of the account drops the row
|
||||
// without a refresh. sessionID may be empty when the store could not report it;
|
||||
// clients then fall back to the globally unique id.
|
||||
// broadcastMessageDeleted 把一次删除作为 `message_deleted` 帧扇出到
|
||||
// owner/admin 连接,携带被删除的 id(外加所属 sessionId,供客户端
|
||||
// 按会话过滤),使该账号的每个浏览器无需刷新即可移除该行。
|
||||
// 当存储无法报告 sessionID 时它可能为空;客户端此时回退到全局
|
||||
// 唯一的 id。
|
||||
func (h *hub) broadcastMessageDeleted(sessionID, id string) {
|
||||
h.broadcast(wsEvent{Type: wsMessageDeleted, SessionID: sessionID, Data: map[string]any{"id": id}})
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"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.
|
||||
// scopeStreamAdapter 是一个 model.Adapter,发出两个文本增量,使测试
|
||||
// 能驱动真实 loop 并观察它产出的成帧事件。
|
||||
type scopeStreamAdapter struct{}
|
||||
|
||||
func (scopeStreamAdapter) Name() string { return "scope-test" }
|
||||
@@ -31,10 +31,10 @@ func (scopeStreamAdapter) ChatStream(_ context.Context, _ model.Request, on func
|
||||
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.
|
||||
// newScopedStreamHub 把 hub 接到真实 loop,其 OnStream 闭包与
|
||||
// cmd/cyrene 的 streamChunk 一致(把整个 StreamEvent 值发布到
|
||||
// ws.stream),因此测试走的是生产路径 OnStream → bus → relay → frame。
|
||||
// 它返回 hub 与一个已注册的假连接。
|
||||
func newScopedStreamHub(t *testing.T) (*hub, *wsConn) {
|
||||
t.Helper()
|
||||
b := bus.New()
|
||||
@@ -58,10 +58,9 @@ func newScopedStreamHub(t *testing.T) (*hub, *wsConn) {
|
||||
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.
|
||||
// readDeltas 扫描连接的帧,直到 want 个 message.delta 帧到达。增量经
|
||||
// bus 异步到达该连接,而 runTurn 自己的 stream_end 是直接发给连接
|
||||
// 的,因此测试不能假定顺序。
|
||||
func readDeltas(t *testing.T, c *wsConn, want int) []wsEvent {
|
||||
t.Helper()
|
||||
var deltas []wsEvent
|
||||
@@ -83,11 +82,11 @@ func readDeltas(t *testing.T, c *wsConn, want int) []wsEvent {
|
||||
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.
|
||||
// TestStreamFramesCarrySessionScope 是 A1 的端到端锁定:经 hub 的
|
||||
// runTurn 驱动的一轮必须发出携带会话与轮次 id 的 message.delta 帧,
|
||||
// 使客户端能把它们路由到活跃会话。它跑真实 loop 而不是直接调用
|
||||
// broadcastStream,这同时证明 bus 原样携带了新字段 —— 无需改动
|
||||
// 发布方。
|
||||
func TestStreamFramesCarrySessionScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb, c := newScopedStreamHub(t)
|
||||
@@ -116,10 +115,9 @@ func TestStreamFramesCarrySessionScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// TestStreamFramesTurnScopeChangesPerTurn 覆盖编排下的续跑形态:在
|
||||
// 首轮完成之后被排空的待处理轮次是第二次 runTurn 调用,因此它必须
|
||||
// 取新的 turnId。否则续跑轮次的增量与上一轮的将无法区分。
|
||||
func TestStreamFramesTurnScopeChangesPerTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb, c := newScopedStreamHub(t)
|
||||
|
||||
@@ -13,21 +13,20 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// relay is the bus handler for the ws.> pattern and the producer bridge
|
||||
// patterns. The ws.chat subject is a streaming trigger (run the loop and emit
|
||||
// chunks); a ws.* subject becomes an outbound event of its design type; a
|
||||
// bridged producer subject (device_update, task output/done, automation result,
|
||||
// reminder, subagent progress) is normalized into the matching ws.* event type.
|
||||
// Events with no WS-visible mapping are dropped so internal agent-to-agent
|
||||
// traffic never leaks to clients.
|
||||
// relay 是 ws.> 模式与各生产者桥接模式的 bus 处理器。ws.chat 主题是
|
||||
// 流式触发器(跑 loop 并发 chunk);ws.* 主题成为其设计类型对应的
|
||||
// 下行事件;被桥接的生产者主题(device_update、任务 output/done、
|
||||
// automation result、reminder、subagent progress)被规范化为匹配的
|
||||
// ws.* 事件类型。没有 WS 可见映射的事件会被丢弃,使内部 agent 间
|
||||
// 流量永不泄露给客户端。
|
||||
func (h *hub) relay(ctx context.Context, e bus.Event) error {
|
||||
if e.Subject == wsChatSubject {
|
||||
go h.streamChat(ctx, e.Payload)
|
||||
return nil
|
||||
}
|
||||
if e.Subject == wsStreamSubject {
|
||||
// OnStream→bus→relay path: the loop already published a structured
|
||||
// model.StreamEvent; map it to the typed downstream event.
|
||||
// OnStream→bus→relay 路径:loop 已经发布了结构化的
|
||||
// model.StreamEvent;把它映射为带类型的下行事件。
|
||||
h.broadcastStream(e.Payload)
|
||||
return nil
|
||||
}
|
||||
@@ -39,12 +38,12 @@ func (h *hub) relay(ctx context.Context, e bus.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamChat is the ws.chat trigger entry point. It decodes the request, and
|
||||
// routes the turn through the session orchestrator when one is wired (the
|
||||
// non-blocking busy/pending/resume path of design/15); otherwise it falls back to
|
||||
// the legacy direct run so a hub built without an orchestrator keeps the exact
|
||||
// stream_chunk/stream_end contract. It runs on its own goroutine so a slow model
|
||||
// call does not stall the bus subscription worker.
|
||||
// streamChat 是 ws.chat 触发器的入口。它解码请求,并在接了
|
||||
// orchestrator 时把该轮路由经会话 orchestrator(design/15 的非阻塞
|
||||
// busy/pending/resume 路径);否则回退到旧的直接运行方式,使未接
|
||||
// orchestrator 的 hub 保持完全相同的 stream_chunk/stream_end 契约。
|
||||
// 它跑在各自的 goroutine 上,因此慢的模型调用不会阻塞 bus 订阅
|
||||
// worker。
|
||||
func (h *hub) streamChat(ctx context.Context, payload any) {
|
||||
var req chatStreamRequest
|
||||
if b, err := json.Marshal(payload); err == nil {
|
||||
@@ -57,15 +56,13 @@ func (h *hub) streamChat(ctx context.Context, payload any) {
|
||||
if req.SessionID == "" {
|
||||
req.SessionID = "web"
|
||||
}
|
||||
// The Web main session is the owner's private conversation (JWT = owner), so
|
||||
// it binds the full-access env: memory read+write, unchanged behavior. The role
|
||||
// comes from the session's binding (design/22 §4.5): a session switched to role Y
|
||||
// answers as Y on the next turn, while an unbound/existing session keeps
|
||||
// resolving to the global default.
|
||||
// Web 主会话是 owner 的私有对话(JWT = owner),因此它绑定全权限
|
||||
// env:记忆读写,行为不变。角色来自会话的绑定(design/22 §4.5):
|
||||
// 切到角色 Y 的会话在下一轮以 Y 应答,而未绑定的/既有的会话继续
|
||||
// 解析为全局默认角色。
|
||||
msgs := userChatMessages(req.Text, req.Image)
|
||||
// Thread the client's deviceID and behavior mode into the orchestrator via ctx
|
||||
// so both the persisted user turn and the assistant reply are tagged
|
||||
// (design/16 §4.5, §5.1).
|
||||
// 通过 ctx 把客户端的 deviceID 与行为模式串进 orchestrator,使
|
||||
// 持久化的用户轮次与助手回复都带上标记(design/16 §4.5、§5.1)。
|
||||
ctx = withMode(withDeviceID(ctx, req.DeviceID), req.Mode)
|
||||
env := h.envResolver.owner(ctx, req.SessionID)
|
||||
if h.orchestrator != nil {
|
||||
@@ -75,20 +72,19 @@ func (h *hub) streamChat(ctx context.Context, payload any) {
|
||||
_, _ = h.runTurn(ctx, req.SessionID, msgs, env)
|
||||
}
|
||||
|
||||
// runTurn executes one agent turn and broadcasts its output stream. It is the
|
||||
// unit both the orchestrator (via its injected TurnRunner) and the legacy
|
||||
// fallback path share, so a turn always emits the same stream_chunk/stream_end
|
||||
// events regardless of who drives it. When the loop is streaming (OnStream wired)
|
||||
// the structured deltas already fan out as message.*/thinking.*/tool_call.*
|
||||
// events through the OnStream→bus→relay path, so only the non-streaming path
|
||||
// emits a single whole-text chunk to hold the stream contract.
|
||||
// runTurn 执行一个 agent 轮次并广播其输出流。它是 orchestrator(经其
|
||||
// 注入的 TurnRunner)与旧回退路径共用的单元,因此无论由谁驱动,一轮
|
||||
// 总是发出相同的 stream_chunk/stream_end 事件。当 loop 处于流式
|
||||
// (接了 OnStream)时,结构化增量已经通过 OnStream→bus→relay 路径以
|
||||
// message.*/thinking.*/tool_call.* 事件扇出,因此只有非流式路径才发出
|
||||
// 一整段文本 chunk 以维持流契约。
|
||||
func (h *hub) runTurn(ctx context.Context, sessionID string, msgs []model.Message, env loop.SessionEnv) (*loop.Result, error) {
|
||||
turnID := newTurnID()
|
||||
// Thread the turn identity into the run context so the loop stamps it on every
|
||||
// streamed event it emits (message.*/thinking.*/tool_call.*); the hub then puts
|
||||
// it on the outbound frame. Every turn takes a fresh id — a session has at most
|
||||
// one in-flight turn — so a client can correlate one burst of deltas to one
|
||||
// assistant turn (design/16 §4.2).
|
||||
// 把轮次身份串进运行上下文,使 loop 把它盖在它发出的每个流式
|
||||
// 事件上(message.*/thinking.*/tool_call.*);hub 随后把它放到
|
||||
// 下行帧上。每一轮都取全新的 id —— 一个会话最多一个在途轮次
|
||||
// —— 因此客户端能把一串增量关联到同一个助手轮次
|
||||
// (design/16 §4.2)。
|
||||
ctx = loop.WithTurnID(ctx, turnID)
|
||||
if h.runner == nil || len(msgs) == 0 {
|
||||
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), SessionID: sessionID, Data: map[string]any{"stopReason": "idle"}})
|
||||
@@ -107,9 +103,9 @@ func (h *hub) runTurn(ctx context.Context, sessionID string, msgs []model.Messag
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// handleInterrupt routes a client "interrupt" frame (design/15 §3) to the
|
||||
// orchestrator, canceling the in-flight turn and clearing the pending queue for
|
||||
// the requested session. It is a no-op when no orchestrator is wired.
|
||||
// handleInterrupt 把客户端的 "interrupt" 帧(design/15 §3)路由到
|
||||
// orchestrator,取消在途轮次并清空指定会话的待处理队列。未接
|
||||
// orchestrator 时是空操作。
|
||||
func (h *hub) handleInterrupt(payload json.RawMessage) {
|
||||
if h.orchestrator == nil {
|
||||
return
|
||||
@@ -124,15 +120,13 @@ func (h *hub) handleInterrupt(payload json.RawMessage) {
|
||||
h.orchestrator.Interrupt(req.SessionID)
|
||||
}
|
||||
|
||||
// broadcastStream maps a streamed model.StreamEvent to its downstream WS event
|
||||
// type and fans it out through the hub's frame gate (design/11 §7.1): every
|
||||
// structured stream frame carries model output, so it reaches owner + admin only.
|
||||
// It runs on the bus subscription worker;
|
||||
// seq is assigned atomically so a burst stays ordered even if the loop emits
|
||||
// events from more than one goroutine. SessionID/TurnID ride on the event itself
|
||||
// (the loop stamps them at the turn boundary from the run context), so the frame
|
||||
// carries the scope a client needs to route it to the active session and to one
|
||||
// assistant turn.
|
||||
// broadcastStream 把流式的 model.StreamEvent 映射为其下行 WS 事件类型,
|
||||
// 并经 hub 的帧门扇出(design/11 §7.1):每一个结构化流帧都携带模型
|
||||
// 输出,因此只到达 owner + admin。它跑在 bus 订阅 worker 上;seq 原子
|
||||
// 分配,因此即便 loop 从多个 goroutine 发出事件,一串帧仍保持有序。
|
||||
// SessionID/TurnID 随事件本身携带(loop 在轮次边界从运行上下文盖上
|
||||
// 它们),因此帧带有客户端把它路由到活跃会话以及关联到某个助手
|
||||
// 轮次所需的范围。
|
||||
func (h *hub) broadcastStream(payload any) {
|
||||
ev, ok := payload.(model.StreamEvent)
|
||||
if !ok {
|
||||
@@ -142,25 +136,23 @@ func (h *hub) broadcastStream(payload any) {
|
||||
h.broadcast(wsEvent{Type: typ, SessionID: ev.SessionID, TurnID: ev.TurnID, Seq: int(h.streamSeq.Add(1)), Data: data})
|
||||
}
|
||||
|
||||
// chatStreamRequest is the decoded ws.chat payload.
|
||||
// chatStreamRequest 是解码后的 ws.chat 载荷。
|
||||
type chatStreamRequest struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Text string `json:"text"`
|
||||
Image string `json:"image,omitempty"`
|
||||
// DeviceID is the client-produced identifier of the sending browser/device
|
||||
// (design/16 §4.5), e.g. "web:chrome:a1b2c3"; it is optional and persisted
|
||||
// alongside the message so the agent knows which end the owner is chatting from.
|
||||
// DeviceID 是客户端生成的发送浏览器/设备标识(design/16 §4.5),
|
||||
// 例如 "web:chrome:a1b2c3";它可选,并随消息一同持久化,使 agent
|
||||
// 知道主人是从哪一端发消息。
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
// Mode is the behavior mode the client sent the turn in (design/01 §3,
|
||||
// design/16 §5.1); it is optional and persisted per message, falling back to
|
||||
// "chat" when omitted.
|
||||
// Mode 是客户端发送该轮时所用的行为模式(design/01 §3、
|
||||
// design/16 §5.1);它可选,按消息持久化,缺省时回退为 "chat"。
|
||||
Mode string `json:"mode,omitempty"`
|
||||
}
|
||||
|
||||
// newTurnID returns a fresh random hex identifier used to correlate a burst of
|
||||
// streamed WS events to one assistant turn. It burns the crypto/rand source and
|
||||
// falls back to a nanosecond timestamp if the source is unavailable, so a id is
|
||||
// always produced (a blank turnId would break client correlation silently).
|
||||
// newTurnID 返回新的随机十六进制标识,用于把一串流式 WS 事件关联到
|
||||
// 同一个助手轮次。它消耗 crypto/rand 源,源不可用时回退到纳秒
|
||||
// 时间戳,因此总能产出 id(空的 turnId 会静默破坏客户端关联)。
|
||||
func newTurnID() string {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err == nil {
|
||||
@@ -169,10 +161,10 @@ func newTurnID() string {
|
||||
return "turn-" + time.Now().UTC().Format("20060102150405.000000000")
|
||||
}
|
||||
|
||||
// publishChatStream publishes a ws.chat streaming trigger (REST stream mode).
|
||||
// deviceID is the sending browser/device id and mode the behavior mode the client
|
||||
// sent the turn in, both carried on the payload so the ws.chat consumer can tag
|
||||
// the persisted turn (design/16 §4.5, §5.1).
|
||||
// publishChatStream 发布一个 ws.chat 流式触发器(REST 流式模式)。
|
||||
// deviceID 是发送方的浏览器/设备 id,mode 是客户端发送该轮时所使用的
|
||||
// 行为模式,二者都随载荷携带,使 ws.chat 消费方能给持久化的轮次打
|
||||
// 标记(design/16 §4.5、§5.1)。
|
||||
func (s *Server) publishChatStream(ctx context.Context, sessionID, text, image, deviceID, mode string) {
|
||||
if s.bus == nil {
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user