Files
Cyrene_v2/internal/config/config_test.go
T
AskaEth 7e7f53bb41
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(orchestration,loop): 子代理现在真的收到任务(P0)+ 工具白名单 + 超时可配(裁决 02.4.2/02.4.1/02.4.4)
执行方案阶段 2 · P2-1。**02.4.2 是最伤的一条 P0**:`spawntool` 从不填 `AgentSpec.Context`、
`scheduler` 把 nil 交给 loop ⇒ 子代理模型上下文 = 角色 system + 记忆召回 + **0 条消息**,
goal/attach_info/data 一个字都到不了模型(此前测试用 mock adapter 恒答固定文本,测不出)。

- **02.4.2**:新增 `taskContext/taskUserMessage`(`spec.go`)把 **goal 正文 + attach_info + data
  渲染成同一条 user 消息**(`[任务目标]/[附加信息]/[任务数据]` 独立段落);`Spawn()` 设
  `Context: taskContext(spec)`;**system 只保留角色指令**,任务绝不进 system。
  真断言(捕获请求体的 adapter + 真 loop/agent):首请求恰好 **1 条 user 消息**、同时含 goal 与
  attach_info,且**所有 system 消息不含 goal 文本**。
- **02.4.1**:`AgentSpec.ToolWhitelist`(nil/空 = 继承全量);schema 增 `tool_whitelist`
  (顶层 + 每 task 条目,task 未带时继承批次级);`scheduler.run()` 经 ctx 传递 +
  `scopedRunner` 派生;`loop/options.go` **纯增量(+75 / −0)**:`WithToolWhitelist`/
  `ToolWhitelistFrom` + `(*Loop).WithToolWhitelist` 派生副本(浅拷贝仅替换 `hooks.CallAdmit`,
  链式保留原钩子),名单外调用否决为**软结果**回灌模型。
- **02.4.4**:`SUBAGENT_TIMEOUT_SEC`(默认 600)入 `internal/config` + `.env.example`;
  优先级 = 按调用 `timeout_seconds`(1s–24h 校验)> `WithSpawnTimeout`(生产由 cfg 填充)>
  调度器 `PerAgentTimeout`(`wire*.go` 仍是 10min 常量,未动);迟到 span 续根由测试锁定。

验证:**全树 `go build`/`go vet`/`go test ./... -count=1` = 58 包 ok / 0 FAIL**;
`GOOS=linux/amd64` + `GOOS=darwin/arm64` 交叉编译绿;前端 `tsc --noEmit` + `npm run build` 绿;
本批文件全部 ≤500 行、函数 ≤80 行。
变异验证(四条,均带还原证明):① `Context` 改回 `cloneMessages(spec.Context)` →
「1 条 user 消息」断言 FAIL(`user messages = 0`,即 P0 症状);② `WithToolWhitelist` 原样返回 →
显式白名单测试 FAIL(`alpha executed 1 times despite not being whitelisted`);
③ `subCtx` 改回全局默认 → 3 个超时测试 FAIL;④ `foldSpanFinish` 丢掉 `SpanFinished` →
迟到 span 状态断言 FAIL。

⚠️ 已知缺口(**登记,未假装已解决**):① 无真实模型端到端(全部捕获请求体 mock);
② 白名单**只挡执行、不过滤工具广告面**(模型仍能看见未列工具,调用即被拒并拿到可读原因;
过滤需改 `loop/request.go`);③ **迟到 span 的"新一轮已开始"窗口未修**(`loop/flow.go` 的
`StartRound` 会整体替换 `graphs[session]` ⇒ 新轮开始后迟到的 span 仍落进新轮图;需按轮键控图,
属后续);④ per-role 超时覆盖未实现(需 persona 旋钮管道)。
2026-10-04 11:30:39 +08:00

387 lines
11 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 config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func writeEnvFile(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, ".env")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write env file: %v", err)
}
return path
}
func TestLoad_DefaultsWhenUnset(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Env != "development" {
t.Errorf("Env = %q, want development", cfg.Env)
}
if cfg.Server.Host != "0.0.0.0" || cfg.Server.Port != 8080 {
t.Errorf("Server = %+v, want 0.0.0.0:8080", cfg.Server)
}
if cfg.LogLevel != "debug" {
t.Errorf("LogLevel = %q, want debug", cfg.LogLevel)
}
if cfg.DB.SQLitePath != "./data/cyrene.db" {
t.Errorf("SQLitePath = %q, want ./data/cyrene.db", cfg.DB.SQLitePath)
}
want := []string{"http://localhost:5173", "http://localhost:3000"}
if !reflect.DeepEqual(cfg.AllowedOrigins, want) {
t.Errorf("AllowedOrigins = %v, want %v", cfg.AllowedOrigins, want)
}
}
func TestLoad_EnvOverridesDefault(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("PORT", "9090")
t.Setenv("LOG_LEVEL", "warn")
t.Setenv("IOT_PROVIDER", "none")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Server.Port != 9090 {
t.Errorf("Port = %d, want 9090", cfg.Server.Port)
}
if cfg.LogLevel != "warn" {
t.Errorf("LogLevel = %q, want warn", cfg.LogLevel)
}
if cfg.IoT.Provider != "none" {
t.Errorf("IoT.Provider = %q, want none", cfg.IoT.Provider)
}
}
func TestLoad_PushOutletEnv(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("PUSH_EXPORTER_ENABLED", "true")
t.Setenv("PUSH_EXPORTER_URL", "https://push.example.com/hook")
t.Setenv("PUSH_EXPORTER_TOKEN", "tok")
t.Setenv("PUSH_EXPORTER_TIMEOUT_SEC", "9")
t.Setenv("GETUI_APP_ID", "app-id")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if !cfg.PushOutlet.Enabled {
t.Error("PushOutlet.Enabled = false, want true")
}
if cfg.PushOutlet.URL != "https://push.example.com/hook" {
t.Errorf("PushOutlet.URL = %q, want the configured endpoint", cfg.PushOutlet.URL)
}
if cfg.PushOutlet.Token != "tok" {
t.Errorf("PushOutlet.Token = %q, want tok", cfg.PushOutlet.Token)
}
if cfg.PushOutlet.TimeoutSec != 9 {
t.Errorf("PushOutlet.TimeoutSec = %d, want 9", cfg.PushOutlet.TimeoutSec)
}
if cfg.PushOutlet.GetuiAppID != "app-id" {
t.Errorf("PushOutlet.GetuiAppID = %q, want app-id", cfg.PushOutlet.GetuiAppID)
}
}
func TestLoad_DotEnvThenProcessEnv(t *testing.T) {
path := writeEnvFile(t, `PORT=7000
LOG_LEVEL=error
# comment line
IOT_PROVIDER=none
`)
// Process env overrides .env for PORT but leaves LOG_LEVEL from .env.
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("PORT", "8000")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load(%q) error: %v", path, err)
}
if cfg.Server.Port != 8000 {
t.Errorf("Port = %d, want 8000 (process env wins)", cfg.Server.Port)
}
if cfg.LogLevel != "error" {
t.Errorf("LogLevel = %q, want error from .env", cfg.LogLevel)
}
if cfg.IoT.Provider != "none" {
t.Errorf("IoT.Provider = %q, want none from .env", cfg.IoT.Provider)
}
}
// unsetEnvForTest clears key for the test and restores the original value (or
// absence) on cleanup, so a .env file is not shadowed by an ambient process env
// variable. config tests are not parallel, so this is safe.
func unsetEnvForTest(t *testing.T, key string) {
t.Helper()
if v, ok := os.LookupEnv(key); ok {
t.Cleanup(func() { _ = os.Setenv(key, v) })
} else {
t.Cleanup(func() { _ = os.Unsetenv(key) })
}
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
}
func TestLoad_LlmFallbackMaxTokens(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
unsetEnvForTest(t, "LLM_FALLBACK_MAX_TOKENS")
// Default: the last-resort fallback budget is a meaningful 8192.
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.LLM.FallbackMaxTokens != 8192 {
t.Errorf("LLM.FallbackMaxTokens = %d, want default 8192", cfg.LLM.FallbackMaxTokens)
}
// A .env file is parsed into the field.
path := writeEnvFile(t, "LLM_FALLBACK_MAX_TOKENS=4096\n")
cfg, err = Load(path)
if err != nil {
t.Fatalf("Load(%q) error: %v", path, err)
}
if cfg.LLM.FallbackMaxTokens != 4096 {
t.Errorf("LLM.FallbackMaxTokens = %d, want 4096 from .env", cfg.LLM.FallbackMaxTokens)
}
// Process env wins over .env.
t.Setenv("LLM_FALLBACK_MAX_TOKENS", "2048")
cfg, err = Load(path)
if err != nil {
t.Fatalf("Load(%q) error: %v", path, err)
}
if cfg.LLM.FallbackMaxTokens != 2048 {
t.Errorf("LLM.FallbackMaxTokens = %d, want 2048 (process env wins)", cfg.LLM.FallbackMaxTokens)
}
}
func TestLoad_MissingRequiredFails(t *testing.T) {
// Do not set JWT_SECRET.
if _, err := Load(""); err == nil {
t.Fatal("expected error when JWT_SECRET missing, got nil")
} else if !strings.Contains(err.Error(), "JWT_SECRET") {
t.Errorf("error should mention JWT_SECRET, got: %v", err)
}
}
func TestLoad_ValidationMinLength(t *testing.T) {
t.Setenv("JWT_SECRET", "short")
if _, err := Load(""); err == nil {
t.Fatal("expected error for JWT_SECRET shorter than 32, got nil")
} else if !strings.Contains(err.Error(), "at least 32") {
t.Errorf("error should mention min length, got: %v", err)
}
}
func TestLoad_InvalidPortFails(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("PORT", "not-a-number")
if _, err := Load(""); err == nil {
t.Fatal("expected error for invalid PORT, got nil")
} else if !strings.Contains(err.Error(), "PORT") {
t.Errorf("error should mention PORT, got: %v", err)
}
}
func TestLoad_MissingDotEnvIsTolerated(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
// A non-existent .env path must not error.
cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.env"))
if err != nil {
t.Fatalf("Load(nonexistent) error: %v", err)
}
if cfg.Server.Port != 8080 {
t.Errorf("Port = %d, want default 8080", cfg.Server.Port)
}
}
func TestLoad_AuthRefreshDefaults(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Auth.RefreshTTL != 720*time.Hour {
t.Errorf("Auth.RefreshTTL = %v, want 720h", cfg.Auth.RefreshTTL)
}
if cfg.Auth.RefreshRotateDisabled {
t.Errorf("Auth.RefreshRotateDisabled = true, want false (rotation on by default)")
}
}
func TestLoad_AuthRefreshEnvOverrides(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("AUTH_REFRESH_TTL", "30m")
t.Setenv("AUTH_REFRESH_ROTATE_DISABLED", "true")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Auth.RefreshTTL != 30*time.Minute {
t.Errorf("Auth.RefreshTTL = %v, want 30m", cfg.Auth.RefreshTTL)
}
if !cfg.Auth.RefreshRotateDisabled {
t.Errorf("Auth.RefreshRotateDisabled = false, want true")
}
}
func TestLoad_CapacityDefaults(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Capacity.Enabled {
t.Errorf("Capacity.Enabled = true, want false (default, opt-in)")
}
if cfg.Capacity.MinConfidence != 0.6 {
t.Errorf("Capacity.MinConfidence = %v, want 0.6", cfg.Capacity.MinConfidence)
}
}
func TestLoad_CapacityEnvOverrides(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("CAPACITY_ENABLED", "true")
t.Setenv("CAPACITY_MIN_CONFIDENCE", "0.75")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if !cfg.Capacity.Enabled {
t.Errorf("Capacity.Enabled = false, want true")
}
if cfg.Capacity.MinConfidence != 0.75 {
t.Errorf("Capacity.MinConfidence = %v, want 0.75", cfg.Capacity.MinConfidence)
}
}
func TestLoad_VoiceDefaults(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Voice.DashscopeBaseURL != "https://dashscope.aliyuncs.com/api/v1" {
t.Errorf("DashscopeBaseURL = %q, want the default Bailian URL", cfg.Voice.DashscopeBaseURL)
}
if cfg.Voice.STTModel != "qwen-audio-3.0-asr-flash" {
t.Errorf("STTModel = %q, want qwen-audio-3.0-asr-flash", cfg.Voice.STTModel)
}
if cfg.Voice.STTLanguage != "zh" {
t.Errorf("STTLanguage = %q, want zh", cfg.Voice.STTLanguage)
}
if cfg.Voice.TTSModel != "cosyvoice-v3-flash" {
t.Errorf("TTSModel = %q, want cosyvoice-v3-flash", cfg.Voice.TTSModel)
}
if cfg.Voice.TTSVoiceID != "longxiaochun_v3" {
t.Errorf("TTSVoiceID = %q, want longxiaochun_v3", cfg.Voice.TTSVoiceID)
}
if cfg.Voice.TTSFormat != "wav" {
t.Errorf("TTSFormat = %q, want wav", cfg.Voice.TTSFormat)
}
if cfg.Voice.TTSSampleRate != 24000 {
t.Errorf("TTSSampleRate = %d, want 24000", cfg.Voice.TTSSampleRate)
}
if cfg.Voice.VoiceTTSInstruction != "" {
t.Errorf("VoiceTTSInstruction = %q, want empty (default)", cfg.Voice.VoiceTTSInstruction)
}
if cfg.Voice.VoiceClonePrefix != "cyrene" {
t.Errorf("VoiceClonePrefix = %q, want cyrene (default)", cfg.Voice.VoiceClonePrefix)
}
}
func TestLoad_VoiceEnvOverrides(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("VOICE_TTS_VOICE_ID", "custom-voice-1")
t.Setenv("VOICE_STT_MODEL", "qwen-audio-3.0-asr-flash")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Voice.TTSVoiceID != "custom-voice-1" {
t.Errorf("TTSVoiceID = %q, want custom-voice-1", cfg.Voice.TTSVoiceID)
}
if cfg.Voice.STTModel != "qwen-audio-3.0-asr-flash" {
t.Errorf("STTModel = %q, want qwen-audio-3.0-asr-flash", cfg.Voice.STTModel)
}
}
func TestLoad_VoiceCloneInstructionEnvOverrides(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
t.Setenv("VOICE_TTS_INSTRUCTION", "语气开朗俏皮,像女朋友撒娇")
t.Setenv("VOICE_CLONE_PREFIX", "xilian")
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Voice.VoiceTTSInstruction != "语气开朗俏皮,像女朋友撒娇" {
t.Errorf("VoiceTTSInstruction = %q, want the env instruction", cfg.Voice.VoiceTTSInstruction)
}
if cfg.Voice.VoiceClonePrefix != "xilian" {
t.Errorf("VoiceClonePrefix = %q, want xilian", cfg.Voice.VoiceClonePrefix)
}
}
func TestParseDotEnv_QuotedAndExport(t *testing.T) {
path := writeEnvFile(t, `export FOO=bar
BAZ='quoted value'
EMPTY=
`)
m, err := parseDotEnv(path)
if err != nil {
t.Fatalf("parseDotEnv error: %v", err)
}
if m["FOO"] != "bar" {
t.Errorf("FOO = %q, want bar", m["FOO"])
}
if m["BAZ"] != "quoted value" {
t.Errorf("BAZ = %q, want 'quoted value'", m["BAZ"])
}
if v, ok := m["EMPTY"]; !ok || v != "" {
t.Errorf("EMPTY = %q (present=%v), want empty", v, ok)
}
}
// TestLoad_SubagentTimeout 验证子代理时限配置(design/05 §7 裁决 02.4.4:
// agent_timeout 可配置,默认 10min):缺省 600 秒,环境变量可覆盖。
func TestLoad_SubagentTimeout(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
cfg, err := Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Subagent.TimeoutSec != 600 {
t.Errorf("Subagent.TimeoutSec = %d, want the 600s (10min) default", cfg.Subagent.TimeoutSec)
}
t.Setenv("SUBAGENT_TIMEOUT_SEC", "90")
cfg, err = Load("")
if err != nil {
t.Fatalf("Load() with override error: %v", err)
}
if cfg.Subagent.TimeoutSec != 90 {
t.Errorf("Subagent.TimeoutSec = %d, want 90 from the environment", cfg.Subagent.TimeoutSec)
}
}