Files
HomeAgent/internal/plugins/webui/handler_persona_test.go
JianFeeeee 46e014a8ca feat(persona): 首启人格门禁跨通道化 + 内核 persona_set 工具
WebUI 首启向导只覆盖 WebUI 这一条通道,而「人格该问一次」是所有通道的事:
走 QQ / CLI / ACP / 邮件来的人永远见不到那个向导,人格就永远是没确认过。

- 门禁移到 buildSystemPrompt(每轮重建 → WebUI/QQ/CLI/ACP/邮件全覆盖),
  以 core.internal.persona_initialized 为准:未确认时要求模型主动询问用户
  (默认 / 自定义 / 以后再说),确认后该段消失;personaStore 为 nil 时静默关闭。
- 新增内核内置工具 persona_set(mode=default|custom|later[, content]),
  落库逻辑与 WebUI 向导**共用 internal/config**(一个实现 + 两个薄入口:
  ConfigRegistry 直连 / 插件侧 SettingsAPI),避免两套语义各自漂移。
- AgentConfig 增加 PersonaStore 接口,cmd/homed 用 RegistryPersonaStore 实现。
- 非法输入(未知 mode / custom 空内容)在打标记**之前**拒绝:否则标记置位、
  向导被跳过,用户再没机会设。

E2E(隔离实例 + 真实 LLM 往返走 /v1/chat/completions,/var/tmp/persona/e2e.sh)9/9 PASS:
未确认时模型主动询问 → 用户答「用默认的」→ 模型调用 persona_set 落库并置位标记
→ 之后不再追问;反向对照(清标记 + 清会话上下文 + 重启)重新开始询问,
排除了「同一段对话里已问过」这一混淆。
2026-09-12 13:57:06 +08:00

146 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package webui
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func newPersonaHandler(t *testing.T) (*Handler, *internalConfig.ConfigRegistry) {
t.Helper()
dir := t.TempDir()
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
cfgReg.SeedDefaults(dir)
t.Cleanup(func() { cfgReg.Close() })
h := NewHandler(testSDK(sdk.SDKConfig{Settings: sdk.NewSettings("webui", cfgReg)}))
return h, cfgReg
}
func doPersona(t *testing.T, h *Handler, method, body string) *httptest.ResponseRecorder {
t.Helper()
var rd *strings.Reader
if body == "" {
rd = strings.NewReader("")
} else {
rd = strings.NewReader(body)
}
req := httptest.NewRequest(method, "/api/v1/persona", rd)
w := httptest.NewRecorder()
h.handlePersona(w, req)
return w
}
// 首启向导的后端契约GET 报告状态、POST 三选一、并且**只问一次**。
func TestPersonaWizardFlow(t *testing.T) {
h, cfgReg := newPersonaHandler(t)
// 1. 全新安装未初始化current_prompt 回落到内置默认模板
w := doPersona(t, h, http.MethodGet, "")
if w.Code != http.StatusOK {
t.Fatalf("GET 状态码 %d", w.Code)
}
var got struct {
Initialized bool `json:"initialized"`
CurrentPrompt string `json:"current_prompt"`
FileOverride bool `json:"file_override"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Initialized {
t.Fatal("全新安装不应已初始化")
}
if got.CurrentPrompt != internalConfig.DefaultPersonaPrompt {
t.Fatal("未设置时应回落到内置默认模板")
}
if got.FileOverride {
t.Fatal("没有人格文件时不应报告 file_override")
}
// 2. 「稍后再说」= 保留默认、打标记、不再问
w = doPersona(t, h, http.MethodPost, `{"mode":"later"}`)
if w.Code != http.StatusOK {
t.Fatalf("later 状态码 %d: %s", w.Code, w.Body.String())
}
if v := cfgReg.GetString(internalConfig.PersonaInitMarkerKey, ""); v == "" {
t.Fatal("later 也必须打一次性标记(否则每次启动都问)")
}
if v := cfgReg.GetString(internalConfig.PersonaPromptKey, ""); v != internalConfig.DefaultPersonaPrompt {
t.Fatalf("later 不应改动人格,实际 %q", v)
}
// 3. 已初始化后 GET 应报 true
w = doPersona(t, h, http.MethodGet, "")
got.Initialized = false
_ = json.Unmarshal(w.Body.Bytes(), &got)
if !got.Initialized {
t.Fatal("打过标记后应报告已初始化")
}
// 4. 自定义:写入内容 + 需要重启(人格在启动时载入)
h2, cfgReg2 := newPersonaHandler(t)
w = doPersona(t, h2, http.MethodPost, `{"mode":"custom","content":"你是测试人格"}`)
if w.Code != http.StatusOK {
t.Fatalf("custom 状态码 %d: %s", w.Code, w.Body.String())
}
var pr struct {
RestartRequired bool `json:"restart_required"`
}
_ = json.Unmarshal(w.Body.Bytes(), &pr)
if !pr.RestartRequired {
t.Fatal("自定义人格应提示需要重启才生效")
}
if v := cfgReg2.GetString(internalConfig.PersonaPromptKey, ""); v != "你是测试人格" {
t.Fatalf("自定义内容未写库: %q", v)
}
// 5. 空内容的 custom 必须被拒(否则等于静默清空人格)
h3, _ := newPersonaHandler(t)
if w = doPersona(t, h3, http.MethodPost, `{"mode":"custom","content":" "}`); w.Code != http.StatusBadRequest {
t.Fatalf("空内容应 400实际 %d", w.Code)
}
// 6. 未知 mode 必须被拒
if w = doPersona(t, h3, http.MethodPost, `{"mode":"nope"}`); w.Code != http.StatusBadRequest {
t.Fatalf("未知 mode 应 400实际 %d", w.Code)
}
// 7. 被拒的请求不得打标记(否则向导会被跳过)
if v := cfgReg2.GetString(internalConfig.PersonaInitMarkerKey, ""); v == "" {
t.Fatal("前置条件:第 4 步已打标记")
}
h4, cfgReg4 := newPersonaHandler(t)
_ = doPersona(t, h4, http.MethodPost, `{"mode":"nope"}`)
if v := cfgReg4.GetString(internalConfig.PersonaInitMarkerKey, ""); v != "" {
t.Fatal("被拒的请求不应打标记")
}
}
// 存在人格文件时 GET 要报告 file_override它会覆盖配置项向导应提示用户
func TestPersonaWizardReportsFileOverride(t *testing.T) {
h, cfgReg := newPersonaHandler(t)
dir := cfgReg.GetString("core.daemon.data_dir", "")
if dir == "" {
t.Fatal("播种应写入 core.daemon.data_dir")
}
if err := os.MkdirAll(filepath.Join(dir, "personal"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "personal", "personal.md"), []byte("旧人格"), 0o644); err != nil {
t.Fatal(err)
}
w := doPersona(t, h, http.MethodGet, "")
var got struct {
FileOverride bool `json:"file_override"`
}
_ = json.Unmarshal(w.Body.Bytes(), &got)
if !got.FileOverride {
t.Fatal("存在 personal.md 时必须报告 file_override")
}
}