Files
Cyrene_v2/internal/runtime/loop/capacity_loop_test.go
T
Aska 6dbb4a2e18
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: 唤醒式架构批次——capacity/L4/会话编排/管理面板写CRUD/前端集成(77-81)
【capacity 唤醒式核心(77)】internal/runtime/capacity:(Decider L1启发式+L2上下文+人格覆盖→三态静息/唤醒/全能→允许工具集+capacity_awaken事件)。loop 外围允许集过滤(不硬阻断=能力≠边界红线)。config 加 Capacity(Enabled/MinConfidence/ToolSets)。wire buildCapacity 接入 loop。

【L4 记忆驱动意图(80)】recall.go:从记忆召回用户习惯(搞一下xx=办事)→唤醒L1/L2漏掉/升置信;群聊陌生人不读owner习惯(隔离红线);RecordIntent闭环(判定写回记忆越用越准);默认保守静息。

【会话消息编排(78)】gateway/session_orchestrator.go:每会话(模式/pending有界5/tail上下文尾巴20);Submit非阻塞(idle立即/busy入队ack/满拒绝);完成回调按序续接(tail+新消息合并增量注入非全量重放);Interrupt中断;REST 202/409+WS session_busy/pending_ack事件;不重造任务引擎复用loop。

【前端集成(79)】ws.ts 加 capacity_awaken/session_busy/pending_ack 解析;store 加 awakenState/sessionBusy;TopBar/ChatShell 加唤醒态徽标+忙态提示(不阻塞输入框);状态组件 status.tsx;Miuix风格。

【管理面板写CRUD(81)】20个写端点(POST/PUT/DELETE /admin/*,owner+审计落库):personas(真持久化YAML)/users apikey(签发掩码+撤销)/iot控制(真下发)/automations(建/触发/删)/llm(test真调用+临时)/channels(connect/close/身份测试actor白名单)/plugins(卸载)/memories(search真检索)/sessions(export)。无存储如实501不伪造。拆4文件满足≤500红线。前端9模块接真写。

【wire收口】buildCapacity接入loop+OnCapacity事件;server.go newOrchestrator接hub。

实机验证:iot ac.living control 200 真实改变 on:true;observability/metrics 200、health ok;全仓 build/vet/test 47包绿+前端tsc/vite。
2026-09-06 21:54:40 +08:00

159 lines
5.5 KiB
Go

package loop
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// capacityToolSet builds a dispatcher over the four representative tools used to
// verify the allowed-set filter: two quiet tools, one awake-only, and one that is
// never allowed in quiet/awake.
func capacityToolSet(t *testing.T) *tool.Dispatcher {
t.Helper()
return dispatcherWith(t,
&mockTool{name: "terminal"},
&mockTool{name: "file"},
&mockTool{name: "web-search"},
&mockTool{name: "json"},
)
}
// toolNames returns the set of tool names advertised in specs. The registry
// List iterates a map, so registration order is not preserved; compare with a
// set (via toolSet) rather than a slice for order-independent assertions.
func toolNames(specs []model.ToolSpec) []string {
out := make([]string, 0, len(specs))
for _, s := range specs {
out = append(out, s.Name)
}
return out
}
func toolSet(specs []model.ToolSpec) map[string]bool {
set := make(map[string]bool, len(specs))
for _, s := range specs {
set[s.Name] = true
}
return set
}
func hasAll(set map[string]bool, names ...string) bool {
for _, n := range names {
if !set[n] {
return false
}
}
return true
}
// TestLoop_Capacity_QuietHidesHeavierTools verifies that with an enabled capacity
// decider a chitchat turn advertises only the quiet allowed set to the model
// (terminal + file, not web-search/json) on the streaming path.
func TestLoop_Capacity_QuietHidesHeavierTools(t *testing.T) {
t.Parallel()
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "陪你"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
l := New(Options{
Agent: a,
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
Capacity: dec,
})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
set := toolSet(ad.streamReqs[0].Tools)
if !hasAll(set, "terminal", "file") {
t.Fatalf("quiet tools = %v, want to include terminal+file", toolNames(ad.streamReqs[0].Tools))
}
if set["web-search"] || set["json"] {
t.Fatalf("quiet tools = %v, want to exclude web-search/json", toolNames(ad.streamReqs[0].Tools))
}
}
// TestLoop_Capacity_WakeExposesWebSearch verifies a task turn exposes the awake
// allowed set (adds web-search) to the model.
func TestLoop_Capacity_WakeExposesWebSearch(t *testing.T) {
t.Parallel()
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "好的,这就查"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
l := New(Options{
Agent: a,
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
Capacity: dec,
})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "帮我查一下最近的天气")}); err != nil {
t.Fatalf("Run: %v", err)
}
set := toolSet(ad.streamReqs[0].Tools)
if !hasAll(set, "terminal", "file", "web-search") {
t.Fatalf("awake tools = %v, want to include terminal+file+web-search", toolNames(ad.streamReqs[0].Tools))
}
if set["json"] {
t.Fatalf("awake tools = %v, want to exclude json", toolNames(ad.streamReqs[0].Tools))
}
}
// TestLoop_Capacity_NilKeepsAllTools verifies the pre-existing behavior: without
// a capacity decider every registered tool is advertised (no filtering).
func TestLoop_Capacity_NilKeepsAllTools(t *testing.T) {
t.Parallel()
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "你好"}}}}
a := newTestAgent(t, ad, td)
l := New(Options{Agent: a, MaxRounds: 8, OnStream: func(model.StreamEvent) {}})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好")}); err != nil {
t.Fatalf("Run: %v", err)
}
set := toolSet(ad.streamReqs[0].Tools)
if !hasAll(set, "terminal", "file", "web-search", "json") {
t.Fatalf("nil capacity tools = %v, want all four", toolNames(ad.streamReqs[0].Tools))
}
if len(set) != 4 {
t.Fatalf("nil capacity tools = %v, want exactly four", toolNames(ad.streamReqs[0].Tools))
}
}
// TestLoop_Capacity_EmitsAwakenEvent verifies a state change surfaces a
// capacity_awaken event through the OnCapacity hook on the streaming path.
func TestLoop_Capacity_EmitsAwakenEvent(t *testing.T) {
t.Parallel()
td := capacityToolSet(t)
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "查到了"}}}}
a := newTestAgent(t, ad, td)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
var got []capacity.Event
l := New(Options{
Agent: a,
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
Capacity: dec,
OnCapacity: func(ev capacity.Event) { got = append(got, ev) },
})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "帮我查一下最近的天气")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(got) != 1 {
t.Fatalf("capacity events = %d, want 1 (got %+v)", len(got), got)
}
ev := got[0]
if ev.Type != capacity.EventAwaken || ev.FromState != capacity.StateQuiet || ev.ToState != capacity.StateAwake {
t.Fatalf("event = %+v, want quiet→awake awaken", ev)
}
if ev.Tool != "web-search" {
t.Fatalf("lit tool = %q, want web-search", ev.Tool)
}
}