feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

按 design/22 §2/§3/§4.4/§7(3/4/5)。
- ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线)
- ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3)
- ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId
- Docs/api-client 版本 +1
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
This commit is contained in:
AskaEth committed 2026-10-03 21:01:33 +08:00
1 parent 3732c538cc
commit 5969bba3bd
22 files changed
+1396 -23

No files matched your search

@@ -140,6 +140,14 @@ type Options struct {
// (channels.json → env fallback). When non-nil the bridge supports hot reload;
// nil keeps the bridge boot-only (Reload returns ErrNilFactory).
Factory PlatformFactory
// ChannelPersona, when non-nil, resolves the role bound to one channel account
// (channels.json ChannelAccount.PersonaID, design/22 §2 方案 b): it is called with
// the platform and the local account id, and returns "" when the account binds no
// role. It is consulted once per adapter connect/reload (never per message) and
// only for the owner's private chat, so a group/stranger conversation can never be
// moved into a role's scope (design/22 §7-4). Nil keeps every channel on the global
// default role.
ChannelPersona func(platform, accountID string) string
}
// Bridge owns the channel adapter lifecycle and the inbound message pipeline. It
@@ -181,6 +189,12 @@ type Bridge struct {
started bool
closed bool
factory PlatformFactory
// channelPersona resolves an account's channel-level role from the effective
// config (design/22 §2 方案 b); adapterPersona caches the resolved role per
// connected adapter so the inbound path never re-reads channels.json. Both are
// guarded by mu.
channelPersona func(platform, accountID string) string
adapterPersona map[string]string
// proactiveCursor is the rotation cursor for the proactive-push no-signal
// fallback (channelbridge/proactive.go), so the default channel is not always
// the first whitelisted owner channel. Guarded by mu.
@@ -251,6 +265,8 @@ func New(opts Options) (*Bridge, error) {
readCancels: make(map[string]context.CancelFunc),
gates: make(map[string]*adapter.FlowGate),
factory: opts.Factory,
channelPersona: opts.ChannelPersona,
adapterPersona: make(map[string]string),
cancel: func() {},
sem: make(chan struct{}, maxConcurrent),
}, nil
@@ -232,7 +232,13 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
// carried so the agent can see who is speaking (design/16 §4.6 §1-2). The
// resolved actor decides who is the owner vs a third party structurally — never
// from prompt text.
//
// The role this turn speaks as follows design/22 §2/§7-3/§7-4: the main session's
// binding when the owner's private chat is mirrored into it, otherwise the
// account's channel-level binding, otherwise the global default; a group or
// stranger conversation is forced onto the default role.
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
runEnv.PersonaID = b.resolvePersona(ctx, sess, target, adapterName(a))
senderName := senderDisplayName(msg, actor)
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
+114
View File
@@ -0,0 +1,114 @@
package channelbridge
import (
"context"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// This file owns which role an inbound IM turn speaks as (DevDocs/design/22 §2
// 裁决规则 + §7-3 + §7-4). Identity and permission stay structural — the actor
// metadata and the session's isolation boundary decide who may do what — while the
// role only scopes the prompt, the memory namespace, the emotion machine and the
// thinking budget.
//
// Precedence, top down (design/22 §2):
// 1. the session-level binding — for an owner private chat that is the main web
// session the message is mirrored into, so the IM mirror answers with the same
// role the Web client is talking to (design/22 §7-3);
// 2. the channel-level binding — the account's channels.json personaId, used when
// the main session binds no role of its own (design/22 §2 方案 b);
// 3. the global default — expressed as "", which the loop resolves to its
// configured default role.
//
// A restricted conversation (group chat or stranger, i.e. every isolated sess-xxx
// session) is deliberately excluded from 1 and 2: it is forced onto the default
// role (design/22 §7-4, the conservative choice), so a channel binding can never
// put a third party's conversation into a role's memory namespace.
// sessionPersonaReader is the optional narrower view of the history store used to
// read a session's bound role. *history.Store implements it; a bridge built
// without one (or with a store that predates design/22) simply resolves no session
// binding, which is the global default.
type sessionPersonaReader interface {
SessionPersona(ctx context.Context, sessionID string) (string, error)
}
// resolvePersona returns the role id one inbound turn runs as. target says where
// the turn's conversation lives (the mirrored main session vs an isolated
// session), sess carries the structurally resolved isolation boundary and account
// is the receiving adapter's registration name (platform:confId) used for the
// channel-level lookup.
func (b *Bridge) resolvePersona(ctx context.Context, sess session.Session, target inboundTarget, account string) string {
// design/22 §7-4: a group/stranger conversation never plays a bound role.
if !target.private {
return ""
}
// design/22 §7-3: an owner private chat mirrors into the main session, so it
// answers as whatever role that session is bound to.
if pid := b.mainSessionPersona(ctx); pid != "" {
return pid
}
// design/22 §2 方案 b: the account's channel-level role, used only when the main
// session binds no role of its own.
return b.personaOfAdapter(account)
}
// mainSessionPersona returns the role bound to the main web session, or "" when
// the session inherits the global default. A missing reader or a read error also
// yields "": a chat must never be blocked by a metadata lookup, and the fallback is
// the pre-existing default role.
func (b *Bridge) mainSessionPersona(ctx context.Context) string {
if b.history == nil {
return ""
}
reader, ok := b.history.(sessionPersonaReader)
if !ok {
return ""
}
pid, err := reader.SessionPersona(ctx, history.MainSessionID)
if err != nil {
return ""
}
return strings.TrimSpace(pid)
}
// personaOfAdapter returns the cached channel-level role of one adapter
// registration name, or "" when none is bound (or none was resolved at connect
// time).
func (b *Bridge) personaOfAdapter(account string) string {
b.mu.RLock()
defer b.mu.RUnlock()
return b.adapterPersona[account]
}
// rememberAdapterPersona resolves and caches the channel-level role of an adapter
// that just connected. Resolving at connect/reload time (rather than per message)
// keeps the inbound hot path free of config reads, while a panel save still takes
// effect immediately: it writes channels.json and calls Reload, which rebuilds and
// reconnects the platform's adapters, refreshing this cache.
func (b *Bridge) rememberAdapterPersona(account string) {
if b.channelPersona == nil {
return
}
platform, accountID, _ := strings.Cut(account, ":")
pid := strings.TrimSpace(b.channelPersona(platform, accountID))
b.mu.Lock()
defer b.mu.Unlock()
if pid == "" {
delete(b.adapterPersona, account)
return
}
b.adapterPersona[account] = pid
}
// forgetAdapterPersona drops an adapter's cached channel role when the account is
// removed (or renamed) by a reload, so a removed account can never keep answering
// as a role that is no longer configured.
func (b *Bridge) forgetAdapterPersona(account string) {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.adapterPersona, account)
}
@@ -0,0 +1,249 @@
package channelbridge
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// This file covers design/22 §2/§7-3/§7-4 from the channel side: which role an
// inbound IM turn answers as. The chain asserted is the structural session → the
// resolved SessionEnv the loop is driven with → the role named in it.
// personaHistory is the bridge's history store with one bound session role, so the
// owner-private mirror can be asserted to follow the main session's role.
type personaHistory struct {
*captureHistory
personas map[string]string
}
func (p *personaHistory) SessionPersona(_ context.Context, sessionID string) (string, error) {
return p.personas[sessionID], nil
}
// envChatter is a Chatter that records the SessionEnv the bridge resolved for the
// turn, which is where the role travels (the loop reads it from ctx).
type envChatter struct {
env loop.SessionEnv
got bool
}
func (e *envChatter) Run(ctx context.Context, _ string, _ []model.Message) (*loop.Result, error) {
e.env = loop.SessionEnvFrom(ctx)
e.got = true
return &loop.Result{Text: "在呢"}, nil
}
// personaBridge builds a started bridge whose single telegram account is bound to
// channelPersona, over a history store that binds mainSessionPersona to the main
// session ("" = unbound).
func personaBridge(t *testing.T, mainSessionPersona, channelPersona string) (*Bridge, *envChatter) {
t.Helper()
chat := &envChatter{}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat,
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: &user.OwnerProfile{},
History: &personaHistory{captureHistory: &captureHistory{}, personas: map[string]string{history.MainSessionID: mainSessionPersona}},
ChannelPersona: func(platform, accountID string) string { return channelPersona },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
b.Start()
t.Cleanup(func() { b.Stop(context.Background()) })
return b, chat
}
// TestInboundOwnerPrivateFollowsMainSessionRole is design/22 §7-3: an owner
// private IM chat is mirrored into the main web session, so it must answer as the
// role that session is bound to — not as the global default and not as the
// account's channel-level role.
func TestInboundOwnerPrivateFollowsMainSessionRole(t *testing.T) {
t.Parallel()
b, chat := personaBridge(t, "xilian", "yuki")
b.process(context.Background(), directMessage())
if !chat.got {
t.Fatal("the loop was never driven for the owner private message")
}
if chat.env.PersonaID != "xilian" {
t.Fatalf("resolved persona = %q, want the main session's binding (xilian)", chat.env.PersonaID)
}
// The session binding wins, so the channel binding is never reached here.
if got := b.personaOfAdapter("telegram"); got != "yuki" {
t.Fatalf("cached channel persona = %q, want yuki (resolved at connect)", got)
}
}
// TestInboundChannelPersonaPrecedence pins the three-state precedence of design/22
// §2: the channel-level binding applies only when neither the session nor anything
// stronger resolves a role.
func TestInboundChannelPersonaPrecedence(t *testing.T) {
t.Parallel()
cases := []struct {
name string
mainSession string
channel string
want string
}{
{name: "session binding wins", mainSession: "xilian", channel: "yuki", want: "xilian"},
{name: "channel binding when session unbound", mainSession: "", channel: "yuki", want: "yuki"},
{name: "default when neither binds", mainSession: "", channel: "", want: ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
b, chat := personaBridge(t, tc.mainSession, tc.channel)
b.process(context.Background(), directMessage())
if !chat.got {
t.Fatal("the loop was never driven")
}
if chat.env.PersonaID != tc.want {
t.Fatalf("resolved persona = %q, want %q", chat.env.PersonaID, tc.want)
}
})
}
}
// TestInboundGroupForcesDefaultRole is design/22 §7-4: a group conversation — even
// the owner's own messages inside it — keeps the default role even when the account
// binds one, so the isolated group session can never be moved into a role's scope.
func TestInboundGroupForcesDefaultRole(t *testing.T) {
t.Parallel()
chat := &envChatter{}
b, err := New(Options{
Loop: chat,
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}},
Owner: &user.OwnerProfile{},
History: &personaHistory{captureHistory: &captureHistory{}, personas: map[string]string{history.MainSessionID: "xilian"}},
ChannelPersona: func(platform, accountID string) string { return "yuki" },
})
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &fakeAdapter{name: "telegram"}
b.Add(ad)
b.Start()
t.Cleanup(func() { b.Stop(context.Background()) })
b.process(context.Background(), adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, GroupID: "-20",
SenderID: "100", Sender: adapter.Actor{Role: adapter.RoleOwner}, Content: "我在群里", ContentType: adapter.ContentText,
})
if !chat.got {
t.Fatal("the loop was never driven")
}
if chat.env.PersonaID != "" {
t.Fatalf("group conversation persona = %q, want the default role (empty)", chat.env.PersonaID)
}
// The isolation boundary itself is untouched by role resolution.
if chat.env.MemWritable {
t.Fatalf("env = %+v, want the read-only restricted lane preserved", chat.env)
}
}
// TestResolvePersonaStrangerUsesDefaultRole covers the other restricted lane
// directly: a stranger's isolated private conversation is not the owner's mirror,
// so both the session binding and the channel binding are skipped (design/22 §7-4).
// The pipeline path is asserted for groups above; a stranger message only reaches
// the loop when the participation gate admits it (design/12 §6), which is
// orthogonal to role resolution and asserted elsewhere.
func TestResolvePersonaStrangerUsesDefaultRole(t *testing.T) {
t.Parallel()
b, err := New(Options{
Loop: &envChatter{},
Sessions: &stubSessions{},
Owner: &user.OwnerProfile{},
History: &personaHistory{captureHistory: &captureHistory{}, personas: map[string]string{history.MainSessionID: "xilian"}},
ChannelPersona: func(platform, accountID string) string { return "yuki" },
})
if err != nil {
t.Fatalf("New: %v", err)
}
stranger := session.Session{SessionID: "sess-s", Owner: false, MemWritable: false}
target := inboundTargetFor(stranger, "telegram")
if target.private {
t.Fatalf("stranger target = %+v, want an isolated (non-mirrored) session", target)
}
if got := b.resolvePersona(context.Background(), stranger, target, "telegram"); got != "" {
t.Fatalf("stranger persona = %q, want the default role (empty)", got)
}
}
// TestChannelPersonaLookupSplitsAccountName proves the account-level lookup: a
// multi-account platform resolves the role of the account that received the
// message (platform:confId), not of the platform as a whole.
func TestChannelPersonaLookupSplitsAccountName(t *testing.T) {
t.Parallel()
var gotPlatform, gotAccount string
b, err := New(Options{
Loop: &envChatter{},
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: &user.OwnerProfile{},
ChannelPersona: func(platform, accountID string) string {
gotPlatform, gotAccount = platform, accountID
if accountID == "conf0" {
return "yuki"
}
return ""
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &fakeAdapter{name: "telegram:conf0"}
b.Add(ad)
b.Start()
t.Cleanup(func() { b.Stop(context.Background()) })
if gotPlatform != "telegram" || gotAccount != "conf0" {
t.Fatalf("lookup = %s/%s, want telegram/conf0", gotPlatform, gotAccount)
}
if got := b.personaOfAdapter("telegram:conf0"); got != "yuki" {
t.Fatalf("cached persona = %q, want yuki", got)
}
if got := b.personaOfAdapter("telegram:conf1"); got != "" {
t.Fatalf("unbound account persona = %q, want empty", got)
}
}
// TestRemovedAccountForgetsChannelPersona locks the reload contract: an account
// dropped by a reload must not keep answering as its old channel role.
func TestRemovedAccountForgetsChannelPersona(t *testing.T) {
t.Parallel()
chat := &envChatter{}
b, err := New(Options{
Loop: chat,
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: &user.OwnerProfile{},
ChannelPersona: func(platform, accountID string) string { return "yuki" },
})
if err != nil {
t.Fatalf("New: %v", err)
}
ad := &fakeAdapter{name: "telegram"}
b.Add(ad)
b.Start()
t.Cleanup(func() { b.Stop(context.Background()) })
if got := b.personaOfAdapter("telegram"); got != "yuki" {
t.Fatalf("cached persona = %q, want yuki", got)
}
b.removeAdapter(ad, nil)
if got := b.personaOfAdapter("telegram"); got != "" {
t.Fatalf("cached persona after removal = %q, want empty", got)
}
}
@@ -74,6 +74,10 @@ func (b *Bridge) connectAndRun(a adapter.PlatformAdapter) {
b.readCancels[a.Name()] = rcancel
b.connected[a.Name()] = true
b.mu.Unlock()
// Cache the account's channel-level role now that the adapter is live: the
// inbound path reads the cache instead of the config, and a reload (which the
// panel's save triggers) re-runs this to pick up a new binding.
b.rememberAdapterPersona(a.Name())
b.wg.Add(1)
go b.readLoop(a, readCtx)
b.log.Info("channel connected", "channel", a.Name())
@@ -169,5 +173,6 @@ func (b *Bridge) removeAdapter(o adapter.PlatformAdapter, c context.CancelFunc)
delete(b.connected, name)
delete(b.readCancels, name)
b.mu.Unlock()
b.forgetAdapterPersona(name)
b.log.Info("channel (reload) removed", "channel", name)
}
@@ -0,0 +1,108 @@
package gateway
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// This file covers the admin half of the channel-level role binding (design/22 §2
// 方案 b / §4.4): PUT /admin/channels/{platform}/config accepts personaId through the
// existing read-modify-write + bridge.Reload path, an unknown role is refused
// instead of binding a role that cannot answer, and the list endpoint reports the
// binding back.
// TestAdminSaveChannelConfigPersonaID saves a channel role, asserts it reached
// channels.json and the list wire shape, then clears it.
func TestAdminSaveChannelConfigPersonaID(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
map[string]any{"id": "conf0", "token": "bot-tok", "personaId": "yuki"}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
accounts, warn := runtimeconfig.LoadChannels(path, nil)
if warn != nil {
t.Fatalf("load channels warn = %v", warn)
}
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].PersonaID != "yuki" {
t.Fatalf("channels.json telegram = %+v, want personaId yuki", accounts["telegram"])
}
if len(store.reloaded) != 1 || store.reloaded[0] != "telegram" {
t.Fatalf("reloaded = %v, want [telegram] (the panel's hot-reload path)", store.reloaded)
}
// The list endpoint reports the binding so the panel can render it.
var listed []struct {
Name string `json:"name"`
Accounts []struct {
ID string `json:"id"`
PersonaID string `json:"personaId"`
} `json:"accounts"`
}
_, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
if err := json.Unmarshal(decodeBody(t, gBody)["data"], &listed); err != nil {
t.Fatalf("decode channels list: %v (body=%s)", err, gBody)
}
var accts []struct {
ID string `json:"id"`
PersonaID string `json:"personaId"`
}
for _, entry := range listed {
if entry.Name == "telegram" {
accts = entry.Accounts
}
}
if len(accts) != 1 || accts[0].PersonaID != "yuki" {
t.Fatalf("GET /admin/channels telegram accounts = %+v, want personaId yuki (body=%s)", accts, gBody)
}
// Saving the same account without a personaId unbinds it.
resp, body = doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
map[string]any{"id": "conf0", "token": "bot-tok"}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("unbind status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
accounts, _ = runtimeconfig.LoadChannels(path, nil)
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].PersonaID != "" {
t.Fatalf("after unbind = %+v, want personaId cleared", accounts["telegram"])
}
}
// TestAdminSaveChannelConfigRejectsUnknownPersonaID keeps the save fail-closed: a
// channel bound to a role the library does not know would silently answer as the
// default role, so it is refused and nothing is written or reloaded.
func TestAdminSaveChannelConfigRejectsUnknownPersonaID(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
map[string]any{"id": "conf0", "token": "bot-tok", "personaId": "nobody"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("unknown personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if !strings.Contains(string(body), "personaId") {
t.Fatalf("rejection body = %s, want it to name personaId", body)
}
if len(store.reloaded) != 0 {
t.Fatalf("reloaded = %v, want none for a refused save", store.reloaded)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("a refused save must not write channels.json (stat err = %v)", err)
}
}
@@ -35,6 +35,9 @@ type AdminChannelAccount struct {
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
MsgPerMin int `json:"msgPerMin,omitempty"`
MaxMessages int `json:"maxMessages,omitempty"`
// PersonaID is the role this account answers as (design/22 §2 方案 b), omitted
// when the account binds none. It is a persona id, not a secret.
PersonaID string `json:"personaId,omitempty"`
// BotID/BotUsername/BotNickname are the connected bot's resolved identity
// (kook fetches it from /api/v3/user/me on connect). They come from the live
// adapter, not the persisted channels.json config, and are non-sensitive (the
@@ -72,6 +75,11 @@ type channelConfigRequest struct {
GroupMsgPerMin int `json:"groupMsgPerMin"`
MsgPerMin int `json:"msgPerMin"`
MaxMessages int `json:"maxMessages"`
// PersonaID is the role this account answers as (design/22 §2 方案 b). An empty
// value leaves the account unbound (the session binding or the global default
// applies); an unknown id is refused with 400 so a save can never bind a role
// that cannot answer.
PersonaID string `json:"personaId"`
}
// adminSaveChannelConfig persists one account's config for a platform into
@@ -95,6 +103,13 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入"))
return
}
// A channel-level role must exist in the persona library, or the account would
// be bound to a role that cannot answer (design/22 §2 方案 b). An omitted/empty
// personaId is always accepted: it means "no channel binding".
if !s.requireKnownPersona(w, req.PersonaID) {
s.auditClaims(r, "channel.save", "error")
return
}
cfg := channelConfigFromRequest(req)
// weixin-ilink may be saved with an empty config: the panel creates the account
// first, then the user scans the QR (get_bot_qrcode) which fills bot_token/bot_id/
@@ -116,6 +131,7 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
GroupMsgPerMin: req.GroupMsgPerMin,
MsgPerMin: req.MsgPerMin,
MaxMessages: req.MaxMessages,
PersonaID: strings.TrimSpace(req.PersonaID),
})
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
s.auditClaims(r, "channel.save", "error")
@@ -253,6 +269,7 @@ func adminChannelAccountOf(acct config.ChannelAccount) AdminChannelAccount {
GroupMsgPerMin: acct.GroupMsgPerMin,
MsgPerMin: acct.MsgPerMin,
MaxMessages: acct.MaxMessages,
PersonaID: acct.PersonaID,
}
}
@@ -0,0 +1,139 @@
package gateway
import (
"context"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file covers the end-to-end half of design/22 §7-5: a session that switched
// roles must keep the conversation it had, and the model must be able to tell
// which utterances came from the earlier role. The chain asserted here is:
// chat_messages (per-message persona_id) → the orchestrator's seeded turn context
// → the loop's speaker attribution → the model.Request the adapter receives.
// requestTexts returns the text of every message of req, in order.
func requestTexts(req model.Request) []string {
out := make([]string, 0, len(req.Messages))
for _, m := range req.Messages {
out = append(out, messageTextOf(m))
}
return out
}
// messageTextOf concatenates the text parts of m.
func messageTextOf(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
b.WriteString(c.Text)
}
}
return b.String()
}
// TestChatTurnCarriesSpeakerAttributionFromTranscript is design/22 §7-5 accepted
// from the process boundary: the session already holds turns spoken by role X, it
// is now bound to role Y, and one more chat turn arrives. The request must carry
// X's earlier utterance with its speaker attributed to X (not to Y), keep every
// stored body verbatim, and still open with Y's own instructions.
func TestChatTurnCarriesSpeakerAttributionFromTranscript(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := personaTestStore(t)
reg := personaTestRegistry(t)
sub, err := st.NewSubSessionFor(ctx, "owner", "yuki")
if err != nil {
t.Fatalf("NewSubSessionFor: %v", err)
}
// The conversation as it stood while the session spoke as X, with per-message
// persona ids exactly as the orchestrator persists them (design/22 §7-5).
for _, m := range []history.Message{
{SessionID: sub, Role: history.RoleUser, Content: "你还记得吗", PersonaID: "xilian", CreatedAt: 1000},
{SessionID: sub, Role: history.RoleAssistant, Content: "我记住了,你喜欢冰美式", PersonaID: "xilian", CreatedAt: 1001},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed transcript: %v", err)
}
}
ad := &personaAdapter{}
xilian := gatewayAgentFor(t, "你是昔涟。", ad)
yuki := gatewayAgentFor(t, "你是由纪。", ad)
names := map[string]string{"xilian": "昔涟", "yuki": "由纪"}
l := loop.New(loop.Options{
Agent: xilian,
Agents: map[string]*agent.Agent{"xilian": xilian, "yuki": yuki},
PersonaID: "xilian",
PersonaName: func(id string) string { return names[id] },
})
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{History: st, Personas: reg, Loop: l})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{"sessionId": sub, "text": "继续"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("chat status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if len(ad.reqs) == 0 {
t.Fatal("the model adapter saw no request")
}
req := ad.reqs[len(ad.reqs)-1]
// The answering role still owns the head of the prompt.
if got := firstSystemText(req); got != "你是由纪。" {
t.Fatalf("first system = %q, want role Y's prompt", got)
}
// X's earlier utterance reached the model, verbatim (the turn is not amnesiac
// across the role switch).
texts := requestTexts(req)
xIdx := -1
for i, txt := range texts {
if txt == "我记住了,你喜欢冰美式" {
xIdx = i
break
}
}
if xIdx < 0 {
t.Fatalf("X's earlier utterance missing from the request: %q", texts)
}
if xIdx == 0 || req.Messages[xIdx].Role == model.RoleSystem {
t.Fatalf("X's utterance must stay a plain assistant message, got %+v", req.Messages[xIdx])
}
if req.Messages[xIdx].SenderName != "xilian" {
t.Fatalf("speaker label = %q, want xilian (the role that actually spoke it)", req.Messages[xIdx].SenderName)
}
// The speaker boundary names X and marks the stretch as not Y's own words.
note := ""
for _, m := range req.Messages {
if m.Role == model.RoleSystem && strings.Contains(messageTextOf(m), "「昔涟」(xilian)") {
note = messageTextOf(m)
break
}
}
if note == "" || !strings.Contains(note, "不是你说的") {
t.Fatalf("no speaker boundary for X found: %q", texts)
}
// No stored body was rewritten, and the newly sent user text carries no system
// metadata (the user lane stays free text, design/11 §10).
for _, m := range req.Messages {
if m.Role != model.RoleUser {
continue
}
if txt := messageTextOf(m); txt != "继续" && txt != "你还记得吗" {
t.Fatalf("user body = %q, want a stored/verbatim user text", txt)
}
}
if xIdx+1 <= len(req.Messages) && strings.Contains(req.Messages[xIdx].Content[0].Text, "[") {
t.Fatalf("attribution leaked into X's body: %q", req.Messages[xIdx].Content[0].Text)
}
}
@@ -243,16 +243,27 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
st.gen++
gen := st.gen
st.mode = ModeThinking
st.tail = o.boundedTail(messages)
// A fresh turn continues the session's conversation: the persisted transcript
// is seeded in front of the just-sent messages (design/15 §2.2/§4, design/22
// §7-5), and its assistant utterances keep the role that spoke them. The read
// happens here, before persistUserMessages below writes the new message, so the
// message just sent is never duplicated in the context. A store without history
// (or an empty session) leaves the turn on the caller's messages alone. It is a
// bounded indexed read (transcriptContextCount rows) under the state lock, which
// no running turn ever holds, so it cannot block a Submit behind a turn.
turn := o.startTurnMessages(ctx, sessionID, messages)
st.tail = o.boundedTail(turn)
runCtx, cancel := context.WithCancel(ctx)
st.cancel = cancel
o.mu.Unlock()
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking})
// Only the messages that arrived now are persisted: the seeded transcript is
// already durable, and re-appending it would duplicate the whole conversation.
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID)
if sync {
res, err := o.run(runCtx, sessionID, messages, env)
res, err := o.run(runCtx, sessionID, turn, env)
if err != nil {
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
}
@@ -260,7 +271,7 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
return SubmitResult{Accepted: true, Immediate: true, Err: err}, res
}
go o.goTurn(sessionID, gen, messages, env, runCtx, cancel, deviceID, msgMode)
go o.goTurn(sessionID, gen, turn, env, runCtx, cancel, deviceID, msgMode)
return SubmitResult{Accepted: true, Immediate: true}, nil
}
@@ -297,7 +308,7 @@ func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Resu
return nil
}
if res != nil && res.Text != "" {
st.tail = o.boundedTail(concatMessages(st.tail, model.TextMessage(model.RoleAssistant, res.Text)))
st.tail = o.boundedTail(concatMessages(st.tail, assistantTailMessage(res.Text, personaID)))
}
if len(st.pending) > 0 {
next := st.pending[0]
@@ -0,0 +1,129 @@
package gateway
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file owns the reconstruction of a turn's conversation context from the
// persisted transcript (design/15 §2.2/§4 "上下文有界:只保留最近 N 条上下文", design/02
// §1 主会话承载完整上下文, design/22 §7-5). A turn used to start from the newly sent
// message alone, which left the model with no sight of the conversation it was
// continuing; the assistant utterances of earlier turns also carried no record of
// which role had spoken them, so a role switched into a session could read another
// role's words as its own.
//
// The seeding fixes both halves: a fresh turn carries the session's most recent
// persisted messages, and every assistant message is labelled with the role that
// spoke it (model.Message.SenderName) so the loop's context assembly can attribute
// it (loop/attribution.go). Only labels travel on the messages — no body is
// rewritten.
//
// Scope note: the seeded window is the conversation, so the loop's per-step
// background memory extraction now reads those recent messages instead of the
// single newly sent one. Extracted items are still filed under the turn's role
// (loop.persistMemories, design/22 §4.2) and re-extraction of the same content is
// deduplicated by the extractor, but a fact first stated while another role was
// active can now be picked up by the current role if it was never extracted at the
// time. Narrowing the extraction input to the current turn's messages is a
// separate decision and is left as a follow-up.
// transcriptContextCount bounds how many persisted messages a fresh turn carries
// into the model context. It mirrors the orchestrator's continuation tail bound
// (defaultMaxTail), so a seeded turn and a resumed turn stay the same size; the
// loop's own context budget still governs compaction beyond that.
const transcriptContextCount = 20
// transcriptReader is the narrow history view the seeding needs. *history.Store
// satisfies it; a store that does not (a test fake, or a gateway built without
// persistence) simply yields no seeded context, so the orchestrator keeps running
// exactly as before.
type transcriptReader interface {
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
}
// startTurnMessages returns the conversation a fresh turn runs on: the session's
// seeded transcript followed by the messages the client just sent. When the
// transcript is unavailable the caller's messages are returned untouched, which is
// the pre-existing behavior.
func (o *Orchestrator) startTurnMessages(ctx context.Context, sessionID string, messages []model.Message) []model.Message {
seed := o.sessionTranscript(ctx, sessionID)
if len(seed) == 0 {
return messages
}
return concatMessages(seed, messages...)
}
// sessionTranscript reads the session's most recent persisted messages and maps
// them to model messages (oldest first). A nil/unreadable store or a read error
// yields nil so a chat is never blocked by a metadata lookup, and a session with
// no history yields nothing — both degrade to the pre-existing "new message only"
// context.
func (o *Orchestrator) sessionTranscript(ctx context.Context, sessionID string) []model.Message {
if o.history == nil || sessionID == "" {
return nil
}
reader, ok := o.history.(transcriptReader)
if !ok {
return nil
}
recent, err := reader.Recent(ctx, sessionID, transcriptContextCount)
if err != nil {
o.log.Debug("turn transcript read", "err", err, "session", sessionID)
return nil
}
out := make([]model.Message, 0, len(recent))
for _, h := range recent {
out = append(out, transcriptMessage(h))
}
return out
}
// transcriptMessage maps one persisted chat message onto the model message a turn
// continues from. An assistant message carries the role that spoke it (the stored
// persona_id of the turn) as its speaker label, so the context can attribute it to
// that role even after the session was switched; a non-assistant message keeps its
// stored sender label (the third-party display name of design/16 §4.6) and is never
// relabelled as a speaking role.
func transcriptMessage(h history.Message) model.Message {
role := historyRoleToModel(h.Role)
msg := model.Message{
Role: role,
Content: []model.Content{{Type: model.ContentTypeText, Text: h.Content}},
}
if h.Image != "" {
msg.Content = append(msg.Content, model.Content{Type: model.ContentTypeImage, ImageURL: h.Image})
}
if role == model.RoleAssistant {
msg.SenderName = h.PersonaID
return msg
}
msg.SenderName = h.SenderName
return msg
}
// historyRoleToModel maps a persisted chat role to the model's role vocabulary,
// defaulting an unknown role to the user lane.
func historyRoleToModel(role string) model.Role {
switch role {
case history.RoleAssistant:
return model.RoleAssistant
case history.RoleSystem:
return model.RoleSystem
default:
return model.RoleUser
}
}
// assistantTailMessage builds the continuation-tail entry for one settled turn's
// reply, labelled with the role that spoke it (design/22 §7-5): the tail is the
// in-memory twin of the seeded transcript, so a role switched in later must be able
// to tell an earlier role's reply apart from its own words on the very next queued
// turn.
func assistantTailMessage(text, personaID string) model.Message {
msg := model.TextMessage(model.RoleAssistant, text)
msg.SenderName = personaID
return msg
}