Files
Cyrene_v2/internal/services/gateway/admin_write_persona_thinking_test.go
T
AskaEth 38a403afca
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(persona,model,loop): 人格 thinking 配置 + 5 个死字段接线 + 推理旋钮(按 provider 映射)
按 DevDocs/design/01 §2.1/§2.2 与 design/04 §3.2 实现(规格含联网核过的官方依据:思考内容刻意不角色化、不写分步思考指令、思考控制只走参数级)。
- 人格 schema 新增 thinking:budgetTokens / effort / showToUser(*bool,省略=nil=保持现行为,显式 false=只落库) / mode(default|on|off→Enable 三态);非法 mode/负预算 → ErrInvalidThinking(不静默)
- 5 个死字段接上注入:personality→values→speechStyle→catchphrase→background 拼为独立段落(带【】小标题,空值不拼);**全空时输出与旧实现逐字节一致**(有专门测试)
- model.Request 新增 Thinking{ BudgetTokens, Enable } 与 ReasoningEffort;新增 model/reasoning.go:厂商识别(模型名→baseURL 兜底)、取值白名单(OpenAI 系 none…max / DeepSeek low|high|max)、非法值降级+WARN、**不支持一律 WARN 而非静默**;Anthropic 映射 thinking{budget_tokens} 并**保证 budget<max_tokens**(不足时抬高 max_tokens+WARN,正是历史上思考吃光预算→空回复那个坑)
- loop:新增 Options.ThinkingShowToUser(*bool) 仅门控 thinking_* 帧(正文/工具帧不受影响);场景级 mute 语义不变
- 补一处任务清单外但必需的接线:persona→model.Request 的 thinking 映射(否则 budget/effort/mode 会变成新的死字段);showToUser 刻意不产生 Thinking(纯呈现开关)
- 管理端:personaUpsertRequest 补 thinking DTO + 校验(400);PUT 省略时**保留已存块**(防旧客户端抹配置);写侧 yaml round-trip 已验证
- Docs/api-client v0.40→v0.41(可选请求字段)
- 测试:26 个新用例(loader 解析/拼接逐字节/映射、anthropic 非流式+流式预算约束、openai 透传/降级/丢弃、Qwen 诚实 WARN、loop 门控、DTO round-trip);3 次变异验证(省略与 false 混同 / 去掉预算约束 / 短路门控 各自 FAIL)
- 验收:go build/vet/test 全绿(含 57 包)+ linux/darwin 交叉编译 + 前端 build;唯一 FAIL 是既有 flake TestInterruptCancelsTurnAndClearsPending(3/20 新代码 vs 3/20 纯 HEAD,已归因非本次引入)
2026-10-03 19:54:22 +08:00

103 lines
4.0 KiB
Go

package gateway
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
)
// TestPersonaUpsertRequest_ThinkingRoundTripsThroughYAML pins the admin persona
// write path for the optional `thinking` block: the hand-written DTO maps into
// the loader struct, yaml.Marshal emits the documented keys, and the reload of
// that YAML yields the same values — i.e. the field really round-trips instead of
// being dropped by an un-whitelisted DTO.
func TestPersonaUpsertRequest_ThinkingRoundTripsThroughYAML(t *testing.T) {
t.Parallel()
show := false
req := personaUpsertRequest{
ID: "xilian", Name: "昔涟", Role: "r", SystemPrompt: "s",
Thinking: &personaThinkingRequest{BudgetTokens: 4096, Effort: " medium ", ShowToUser: &show, Mode: " on "},
}
p := personaFromRequest(req)
if err := p.ValidateThinking(); err != nil {
t.Fatalf("ValidateThinking() error: %v", err)
}
data, err := yaml.Marshal(p)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
back := &loader.Persona{}
if err := yaml.Unmarshal(data, back); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if back.Thinking.BudgetTokens != 4096 || back.Thinking.Mode != loader.ThinkingModeOn || back.Thinking.Effort != "medium" {
t.Fatalf("thinking = %+v, want 4096/on/medium", back.Thinking)
}
if back.Thinking.ShowToUser == nil || *back.Thinking.ShowToUser {
t.Fatalf("showToUser = %v, want an explicit false", back.Thinking.ShowToUser)
}
}
// TestPersonaUpsertRequest_ThinkingInvalidRejected asserts an invalid thinking
// block is rejected through the same validation the loader uses, so the admin API
// cannot persist a mode the runtime would refuse to load.
func TestPersonaUpsertRequest_ThinkingInvalidRejected(t *testing.T) {
t.Parallel()
p := personaFromRequest(personaUpsertRequest{
ID: "x", Name: "x", Role: "r", Thinking: &personaThinkingRequest{Mode: "always"},
})
err := p.ValidateThinking()
if err == nil {
t.Fatal("ValidateThinking() = nil, want an error")
}
if !strings.Contains(err.Error(), "always") {
t.Fatalf("error %q does not name the offending mode", err)
}
}
// TestPersonaUpsertRequest_NoThinkingBlockOmitsTheKey asserts a request without a
// thinking block produces none in the YAML, so no existing persona file gains a
// noise section.
func TestPersonaUpsertRequest_NoThinkingBlockOmitsTheKey(t *testing.T) {
t.Parallel()
data, err := yaml.Marshal(personaFromRequest(personaUpsertRequest{ID: "x", Name: "x", Role: "r", SystemPrompt: "s"}))
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if strings.Contains(string(data), "thinking") {
t.Fatalf("marshaled persona carries a thinking block:\n%s", data)
}
}
// TestApplyThinkingEdit_PreservesOnOmission is the non-destructive update rule: a
// client that omits `thinking` keeps the stored block, an explicit block replaces
// it, and an all-default block clears it.
func TestApplyThinkingEdit_PreservesOnOmission(t *testing.T) {
t.Parallel()
show := false
stored := &loader.Persona{Thinking: loader.ThinkingParams{BudgetTokens: 8192, Mode: loader.ThinkingModeOn, ShowToUser: &show}}
kept := applyThinkingEdit(nil, stored)
if kept.BudgetTokens != 8192 || kept.Mode != loader.ThinkingModeOn || kept.ShowToUser == nil {
t.Fatalf("applyThinkingEdit(nil, stored) = %+v, want the stored block", kept)
}
replaced := applyThinkingEdit(&personaThinkingRequest{BudgetTokens: 1024, Mode: "off"}, stored)
if replaced.BudgetTokens != 1024 || replaced.Mode != loader.ThinkingModeOff || replaced.ShowToUser != nil {
t.Fatalf("applyThinkingEdit(explicit, stored) = %+v, want the request block", replaced)
}
cleared := applyThinkingEdit(&personaThinkingRequest{}, stored)
if cleared.BudgetTokens != 0 || cleared.Mode != "" || cleared.ShowToUser != nil {
t.Fatalf("applyThinkingEdit(empty object, stored) = %+v, want the zero block", cleared)
}
if created := applyThinkingEdit(nil, nil); created.BudgetTokens != 0 || created.ShowToUser != nil {
t.Fatalf("applyThinkingEdit(nil, nil) = %+v, want the zero block (create path)", created)
}
}