From a57401a202cfdb4df7aa97210c6d45ed6535aa2e Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sun, 12 Apr 2026 12:57:00 +0800 Subject: [PATCH 1/5] =?UTF-8?q?refactor(config):=E4=BC=98=E5=8C=96config?= =?UTF-8?q?=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 + docs/config-management-detail-design.md | 156 ++- docs/guides/adding-providers.md | 10 +- docs/guides/configuration.md | 396 +++--- internal/app/bootstrap.go | 7 +- internal/app/bootstrap_test.go | 25 +- internal/config/config_test.go | 231 ++-- internal/config/default_config.go | 14 - internal/config/default_config_test.go | 50 - internal/config/defaults_test.go | 56 + internal/config/loader.go | 79 +- internal/config/loader_test.go | 39 +- internal/config/manager.go | 24 +- internal/config/model.go | 180 +-- internal/config/model_test.go | 1069 +++++++++++++++++ ...rovider_adapter.go => provider_runtime.go} | 32 +- internal/config/provider_runtime_test.go | 40 + internal/config/selection.go | 406 ------- internal/config/state/model.go | 152 +++ .../config/state/model_additional_test.go | 520 ++++++++ .../model_test.go} | 68 +- internal/config/state/service.go | 319 +++++ .../service_test.go} | 160 ++- internal/context/compact/runner.go | 2 +- internal/context/test_helpers_test.go | 13 +- internal/provider/catalog/additional_test.go | 25 +- internal/provider/catalog/service_test.go | 44 +- internal/runtime/compact.go | 16 +- internal/runtime/runtime.go | 3 +- internal/runtime/runtime_test.go | 7 +- internal/tui/bootstrap/builder.go | 7 +- internal/tui/bootstrap/builder_test.go | 21 +- internal/tui/core/app/app.go | 7 +- internal/tui/core/app/commands.go | 9 +- internal/tui/core/app/commands_test.go | 12 +- internal/tui/core/app/update_test.go | 47 +- internal/tui/services/provider_service.go | 10 +- internal/tui/services/services_test.go | 18 +- 38 files changed, 2914 insertions(+), 1362 deletions(-) delete mode 100644 internal/config/default_config.go delete mode 100644 internal/config/default_config_test.go create mode 100644 internal/config/defaults_test.go create mode 100644 internal/config/model_test.go rename internal/config/{provider_adapter.go => provider_runtime.go} (53%) create mode 100644 internal/config/provider_runtime_test.go delete mode 100644 internal/config/selection.go create mode 100644 internal/config/state/model.go create mode 100644 internal/config/state/model_additional_test.go rename internal/config/{provider_adapter_test.go => state/model_test.go} (64%) create mode 100644 internal/config/state/service.go rename internal/config/{selection_test.go => state/service_test.go} (81%) diff --git a/AGENTS.md b/AGENTS.md index 1002316b..79579751 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ ## 4. AI 修改代码时的执行流程 - 先定位改动所属模块,再检查是否会破坏职责边界。 - 优先做最小闭环改动,避免无关重构。 +- 项目中可能存在语义不清的地方,必须要谨慎分析 - 如果新增能力会被模型调用,先设计 `tools` 抽象,再接入 `runtime`。 - 如果改动涉及 provider 协议,差异收敛在 `internal/provider` 内,不向外扩散特定厂商字段。 - 如果改动涉及配置,统一经由 `ConfigManager` 或现有配置管理入口处理。 @@ -93,6 +94,7 @@ - 避免硬编码业务语义字符串或状态值(例如消息角色、事件类型、协议字段值);如需复用,优先收敛到共享常量、类型或统一定义处。 - 优先写清晰、可替换的实现,不要为了“未来可能需要”提前引入复杂泛化。 - 不要擅自删除、改写或遗漏原有注释;若注释需要调整,应在理解原意后做等价更新,而不是直接移除。 +- 如果有需要,可以使用文档块注释,并使用 `//` 或 `/* */` - 如果文件内容疑似乱码或编码异常,先尝试用其他编码方式查看和确认原文,再决定是否修改;未确认前不要擅自删除或重写相关内容。 ## 7. 配置与安全规则 diff --git a/docs/config-management-detail-design.md b/docs/config-management-detail-design.md index 8975eb90..1d41e13c 100644 --- a/docs/config-management-detail-design.md +++ b/docs/config-management-detail-design.md @@ -1,34 +1,134 @@ # 配置管理模块详细设计 + ## 模块职责 -`config` 模块主要负责四类事情: -- 加载和保存 YAML 配置文件 -- 从环境变量解析真实密钥 -- 管理 NeoCode 托管目录中的配置与 `.env` -- 向运行中的系统提供并发安全的配置读写能力 + +`internal/config` 只负责以下几件事: + +- 读取和保存 `~/.neocode/config.yaml` +- 提供静态默认值 +- 组装运行时 provider 集合 + - builtin provider 来自代码内置定义 + - custom provider 来自 `~/.neocode/providers//provider.yaml` +- 提供并发安全的配置快照读写能力 +- 在真正发起请求前,根据环境变量解析 API Key + +`internal/config` 不负责以下事情: + +- 不自动加载 `.env` +- 不兼容或清洗旧版 YAML 字段 +- 不判断 `selected_provider/current_model` 是否已经可直接运行 +- 不承担模型发现、目录缓存或运行时选择修正 + +最后一项由 `internal/config/state` 负责:它基于当前运行时支持情况和 catalog 结果修正选择状态,并将变更持久化。 ## 核心类型 -- `Config`:顶层应用配置,运行时包含 Provider 列表、当前选中 Provider、当前模型、工作目录、Shell 和循环限制等信息 -- `ProviderConfig`:单个 Provider 的内建定义,包括 Base URL、默认模型、实例级模型列表和 API Key 环境变量名 -- `Manager`:使用 `sync.RWMutex` 保护的配置访问器与修改器 -- `Loader`:对 YAML 文件和托管 `.env` 文件的文件系统封装 - -## 环境变量策略 -- YAML 不保存 `api_key_env`,只保存 `selected_provider`、`current_model` 和通用运行配置。 -- `Loader.LoadEnvironment` 会尝试加载当前工作目录下的 `.env` 和 NeoCode 托管目录中的 `.env`。 -- `ProviderConfig.ResolveAPIKey` 在真正发起请求前通过 `os.Getenv` 读取密钥。 - -## 运行时更新 -- TUI 只能通过 `ConfigManager.Update` 修改配置。 -- TUI 只负责切换当前 Provider 和当前模型,不直接修改 Provider 元数据。 -- `base_url`、`api_key_env`、`driver` 和 `models` 由代码内建定义提供,不从 YAML 读写。 -- 修改模型时,只更新 `current_model`;当前 Provider 的 `model` 仍表示默认模型,`models` 负责描述该实例可选模型列表。 - -## 默认值治理 -- 默认 Provider 名称、URL、默认模型、模型列表和环境变量名统一由内建 Provider 定义提供。 -- `Loader` 在加载旧配置时会丢弃 `providers` / `provider_overrides`,重新回到“YAML 只保存选择状态”的最小结构。 -- Provider 的可选模型目录属于实例配置,进入运行时 `Config` 后再提供给 TUI 和 runtime,避免 TUI 或 driver 自己维护一套零散常量。 + +- `config.StaticDefaults()` + - 返回 config 层负责的静态默认值骨架 + - 不包含 provider 装配,也不保证选择状态可运行 +- `config.Loader` + - 负责 `config.yaml` 与 custom provider 文件的文件系统读写 +- `config.Manager` + - 提供线程安全的快照读取、更新与保存 +- `config.Config` + - 顶层运行时配置快照 +- `config.Config.ValidateSnapshot()` + - 只校验配置快照本身是否结构完整 + - 不等价于“当前配置已经 runtime-ready” + +## 配置来源 + +### 1. 静态默认值 + +静态默认值由 `config.StaticDefaults()` 提供,覆盖: + +- `workdir` +- `shell` +- `max_loops` +- `tool_timeout_sec` +- `context` +- `tools` + +这些默认值只表达“缺省配置长什么样”,不代表完整可运行配置。 + +### 2. builtin provider + +builtin provider 由 `internal/config/builtin_providers.go` 提供。这里集中维护: + +- provider 名称 +- driver +- base URL +- 默认模型 +- API Key 对应的环境变量名 + +### 3. custom provider + +custom provider 来自: + +```text +~/.neocode/providers//provider.yaml +``` + +当前只接受明确受支持的字段;未知字段会直接报错,不做“旧格式自动迁移”。 + +## 加载流程 + +启动时配置加载流程如下: + +1. 构造 `StaticDefaults` +2. 读取 `config.yaml` +3. 严格解析 YAML + - 使用 `KnownFields(true)` + - 未知字段直接失败 +4. 扫描 custom provider 目录 +5. 组装 builtin/custom provider 集合 +6. 应用静态默认值 +7. 执行 `ValidateSnapshot()` +8. 交给 `config/state.Service.EnsureSelection()` 修正选择状态 + +这里有两个明确边界: + +- `ValidateSnapshot()` 只验证“配置快照是否完整” +- `EnsureSelection()` 才处理“当前选择是否还能运行” + +## 持久化策略 + +`config.yaml` 只持久化用户可编辑的最小运行时状态,不持久化 provider 元数据。 + +当前持久化内容包括: + +- `selected_provider` +- `current_model` +- `shell` +- `max_loops` +- `tool_timeout_sec` +- `context` +- `tools` + +当前不写回 `config.yaml` 的内容包括: + +- `providers` +- `base_url` +- `api_key_env` +- `models` +- `workdir` + +`workdir` 只来自启动默认值或 CLI 覆盖,不进入 YAML。 ## 安全约束 -- 读操作统一走 `Get`,并返回拷贝后的配置快照。 -- 写操作统一走 `Update`,修改前后都要做校验。 -- 真实密钥不能出现在日志、状态栏、聊天流或错误提示中。 + +- API Key 只从环境变量读取 +- YAML 中不保存明文密钥 +- 配置访问统一走 `Manager.Get()` / `Manager.Update()` +- `Get()` 返回克隆快照,避免共享可变状态 +- `Update()` 在保存前重新执行默认值补齐与快照校验 + +## 设计结论 + +`config` 层的目标不是“替运行时兜底一切”,而是提供一份边界清晰、结构稳定、可验证的配置快照。 + +因此这里坚持三条规则: + +- 不在 `config` 中堆兼容旧字段的逻辑 +- 不把选择修正混进快照校验 +- 不把 provider/catalog/runtime 语义倒灌回 `config` diff --git a/docs/guides/adding-providers.md b/docs/guides/adding-providers.md index db3a0995..95d94b01 100644 --- a/docs/guides/adding-providers.md +++ b/docs/guides/adding-providers.md @@ -98,8 +98,11 @@ package anthropic import ( "context" + "errors" "net/http" - + "strings" + "time" + "neo-code/internal/config" domain "neo-code/internal/provider" ) @@ -117,8 +120,8 @@ type Provider struct { // New 构造函数 func New(cfg config.ResolvedProviderConfig) (*Provider, error) { - if err := cfg.Validate(); err != nil { - return nil, fmt.Errorf("anthropic provider: %w", err) + if strings.TrimSpace(cfg.APIKey) == "" { + return nil, errors.New("anthropic provider: api key is empty") } return &Provider{ cfg: cfg, @@ -267,4 +270,3 @@ func DefaultProviders() []ProviderConfig { ``` 所有内置 provider 都通过代码集中注册。模型选择器展示的候选模型由默认模型、动态发现结果和本地缓存共同组成。 - diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 07d3e01b..fcc7293a 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -1,346 +1,224 @@ # 配置指南 -本文档说明 NeoCode 的配置策略和配置文件结构。 +本文说明 NeoCode 当前真实生效的配置规则。 -## Provider 策略 +## 总原则 -NeoCode 采用"内置 Provider 优先"的策略: +- `config.yaml` 只保存最小运行时状态 +- provider 元数据来自代码内置定义或 custom provider 文件 +- API Key 只从环境变量读取 +- YAML 采用严格解析,未知字段直接报错 -### 核心原则 +这意味着 NeoCode 当前不会: -✅ **配置集中管理** -- 所有内置 provider 定义集中在 `internal/config/builtin_providers.go` -- 配置随代码版本发布,自动更新 +- 自动清理旧版 `providers` / `provider_overrides` +- 自动兼容 `workdir`、`default_workdir` 等旧字段 +- 自动加载 `.env` -✅ **最小持久化** -- `config.yaml` 不再持久化完整 `providers` 列表 -- 只保存当前选择状态和通用运行配置 -- 运行时的 `providers` 完全来自代码内置定义 -- 用户不能通过 YAML 注入新的 provider +## 配置文件位置 -✅ **安全第一** -- API Key 只从环境变量读取,永不写入 YAML -- 不硬编码在源码中 +主配置文件路径: -### 当前内置 Provider - -| Provider | Driver | 说明 | -|----------|--------|------| -| `openai` | `openai` | OpenAI 官方 API | -| `gemini` | `openai` | Google Gemini (OpenAI-compatible API) | -| `openll` | `openai` | OpenLL 服务 (OpenAI-compatible API) | -| `qiniu` | `openai` | 七牛云推理服务 (OpenAI-compatible API) | - -所有内置 provider 都复用 `openai` 驱动,支持流式输出和 Tool Call。 - -### 设计优势 - -这种方式意味着: - -- ✅ 新用户启动后自动获得当前版本最新的内置 provider -- ✅ 未来代码新增 provider 时,用户无需修改 YAML -- ✅ 老配置文件中的 `providers` / `provider_overrides` 会在加载时被清理 -- ✅ 配置文件始终保持简洁,只包含必要的运行时状态 -- ✅ 如需新增 provider,只能通过代码扩展内建列表完成 - -## 配置文件 +```text +~/.neocode/config.yaml +``` -### 默认路径 +custom provider 目录: +```text +~/.neocode/providers//provider.yaml ``` -~/.neocode/config.yaml -``` -### 完整配置示例 +## `config.yaml` 可写字段 + +当前支持的主配置示例: ```yaml selected_provider: openai current_model: gpt-4.1 -workdir: /Users/username/projects/myproject shell: bash max_loops: 8 +tool_timeout_sec: 20 tools: webfetch: - max_response_bytes: 1048576 + max_response_bytes: 262144 supported_content_types: - text/html - text/plain - application/json -``` - -### 字段说明 - -#### 基础配置 - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `selected_provider` | string | `openai` | 当前选择的 provider 名称 | -| `current_model` | string | 取决于 provider | 当前选择的模型名称 | -| `workdir` | string | `.` (当前目录) | 工作目录的绝对路径 | -| `shell` | string | `bash` (Linux/Mac)
`powershell` (Windows) | Shell 类型 | -| `max_loops` | int | `8` | Agent 推理循环最大轮数 | - -#### 工具配置 - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `tools.webfetch.max_response_bytes` | int | `1048576` (1MB) | WebFetch 工具最大响应字节数 | -| `tools.webfetch.supported_content_types` | []string | `[text/html, text/plain, application/json]` | 支持的内容类型 | - -### 配置文件特点 - -**自动管理**: -- 首次启动时自动创建默认配置 -- `workdir` 自动转换为绝对路径 -- 无效配置会在启动时报错 - -**最小持久化**: -- 不保存 `providers` 列表(由代码内置提供) -- 不保存 `base_url`、`models` 等 provider 元数据 -- 只保存用户的选择状态和自定义设置 - -## 环境变量 -每个 provider 对应的 API Key 环境变量: - -| Provider | 环境变量 | 获取方式 | -|----------|----------|----------| -| `openai` | `OPENAI_API_KEY` | [OpenAI Platform](https://platform.openai.com/api-keys) | -| `gemini` | `GEMINI_API_KEY` | [Google AI Studio](https://aistudio.google.com/apikey) | -| `openll` | `AI_API_KEY` | OpenLL 服务提供商 | -| `qiniu` | `QINIU_API_KEY` | 七牛云推理服务 | - -### 环境变量管理 - -#### 方式:系统环境变量 - -**Linux/macOS**: -```bash -export OPENAI_API_KEY="sk-..." -export GEMINI_API_KEY="AI..." -export AI_API_KEY="your-key" -``` - -**Windows PowerShell**: -```powershell -$env:OPENAI_API_KEY="sk-..." -$env:GEMINI_API_KEY="AI..." -$env:AI_API_KEY="your-key" +context: + compact: + manual_strategy: keep_recent + manual_keep_recent_messages: 10 + max_summary_chars: 1200 + micro_compact_disabled: false + auto_compact: + enabled: false + input_token_threshold: 100000 ``` -### 安全最佳实践 +### 基础字段 -⚠️ **重要安全提示**: +| 字段 | 说明 | +|------|------| +| `selected_provider` | 当前选中的 provider 名称 | +| `current_model` | 当前选中的模型 ID | +| `shell` | 默认 shell,Windows 默认 `powershell`,其他平台默认 `bash` | +| `max_loops` | Agent 主循环最大轮数 | +| `tool_timeout_sec` | 工具执行超时(秒) | -1**使用环境变量** - - API Key 仅从环境变量读取 - - 永不写入配置文件 - - 不硬编码在代码中 +### `context` 字段 -2**密钥轮换** - - 定期更换 API Key - - 不要在多个环境使用同一个 Key +| 字段 | 说明 | +|------|------| +| `context.compact.manual_strategy` | `/compact` 手动压缩策略,支持 `keep_recent` / `full_replace` | +| `context.compact.manual_keep_recent_messages` | `keep_recent` 策略下保留的最近消息数 | +| `context.compact.max_summary_chars` | compact summary 最大字符数 | +| `context.compact.micro_compact_disabled` | 是否关闭默认启用的 micro compact | +| `context.auto_compact.enabled` | 是否启用自动压缩 | +| `context.auto_compact.input_token_threshold` | 自动压缩输入 token 阈值 | -## Slash Commands +### `tools` 字段 -NeoCode 提供以下 slash 命令用于快速切换配置: +| 字段 | 说明 | +|------|------| +| `tools.webfetch.max_response_bytes` | WebFetch 最大响应字节数 | +| `tools.webfetch.supported_content_types` | WebFetch 允许的内容类型 | +| `tools.mcp.servers` | MCP server 列表 | -### /provider - Provider 选择器 +## 不写入 `config.yaml` 的字段 -``` -/provider -``` +以下内容不允许写入主配置文件: -打开 provider 选择器,列出所有可用的内置 provider。 +- `providers` +- `provider_overrides` +- `workdir` +- `default_workdir` +- `base_url` +- `api_key_env` +- `models` -**界面示例**: -``` -? Select a provider: - ❯ openai - gemini - openll - qiniu -``` +如果这些字段出现在 `config.yaml` 中,加载会直接失败,而不是被“自动迁移”或“悄悄清理”。 -### /model - 模型选择器 +## provider 策略 -``` -/model -``` +NeoCode 采用“builtin provider + custom provider”双来源模型。 -打开当前 provider 的模型选择器。 +### builtin provider -模型列表会合并当前 provider 的默认模型、服务端动态发现结果和本地缓存结果。 +builtin provider 由代码内置,集中定义在: -**界面示例**(选择 openai provider 后): -``` -? Select a model: - ❯ gpt-4.1 - gpt-4o - gpt-5.4 - gpt-5.3-codex +```text +internal/config/builtin_providers.go ``` -## 配置管理 - -配置管理由 `internal/config` 模块负责: +当前内置 provider: -### 核心功能 +- `openai` +- `gemini` +- `openll` +- `qiniu` -- ✅ YAML 加载与保存 -- ✅ 默认值管理 -- ✅ 并发安全访问 -- ✅ 配置校验 +### custom provider -### 配置流程 +custom provider 通过单独文件声明,而不是写进 `config.yaml`: +```yaml +name: company-gateway +driver: openaicompat +api_key_env: COMPANY_GATEWAY_API_KEY +openai_compatible: + base_url: https://llm.example.com/v1 + api_style: chat_completions ``` -启动 - ↓ -加载 ~/.neocode/config.yaml - ↓ -应用内置 defaults (来自 builtin_providers.go) - ↓ -验证配置完整性 - ↓ -运行时使用 -``` - -### 配置更新 -当用户通过 TUI 切换 provider 或 model 时: +文件路径: -1. 更新内存中的配置 -2. 立即持久化到 `config.yaml` -3. 下次启动自动恢复选择状态 - -## 扩展内建 Provider - -如需添加新的内建 provider(包括企业内部服务),请参考: - -👉 [adding-providers.md](./adding-providers.md) +```text +~/.neocode/providers/company-gateway/provider.yaml +``` -### 快速步骤 +## 环境变量 -**OpenAI 兼容服务**(推荐): -1. 在 `internal/config/builtin_providers.go` 添加配置函数 -2. 在 `DefaultProviders()` 中注册 -3. 设置对应的环境变量 +API Key 只从系统环境变量读取。 -**自定义协议**: -1. 在 `internal/provider/yourprovider/` 实现驱动 -2. 在 `internal/provider/builtin/builtin.go` 注册驱动 -3. 在 `internal/config/builtin_providers.go` 添加配置 +常见映射: -## 配置示例场景 +| Provider | 环境变量 | +|----------|----------| +| `openai` | `OPENAI_API_KEY` | +| `gemini` | `GEMINI_API_KEY` | +| `openll` | `AI_API_KEY` | +| `qiniu` | `QINIU_API_KEY` | -### 场景一:使用 Gemini +示例: ```bash -# 1. 设置环境变量 -export GEMINI_API_KEY="your-gemini-api-key" - -# 2. 启动 NeoCode -go run ./cmd/neocode - -# 3. 在 TUI 中切换 -/provider # 选择 gemini -/model # 选择 gemini-2.5-flash +export OPENAI_API_KEY="sk-..." +export GEMINI_API_KEY="AI..." ``` -### 场景二:使用 OpenLL - -```bash -# 1. 设置环境变量 -export AI_API_KEY="your-openll-api-key" +Windows PowerShell: -# 2. 启动并切换 -go run ./cmd/neocode -# 在 TUI 中: /provider → openll +```powershell +$env:OPENAI_API_KEY = "sk-..." +$env:GEMINI_API_KEY = "AI..." ``` -### 场景三:自定义工作目录 +## 启动时的选择修正 -```yaml -# ~/.neocode/config.yaml -selected_provider: openai -current_model: gpt-4.1 -workdir: /Users/username/projects/myproject -shell: bash -max_loops: 10 -``` +`config.yaml` 里的 `selected_provider/current_model` 表达的是“用户上次保存的选择状态”。 -## 故障排查 +启动时系统还会基于当前 provider、driver 支持情况和模型目录快照执行选择修正。因此需要区分两件事: -### 配置加载失败 +- 配置快照结构合法 +- 当前选择已经可直接运行 -**错误**:`config validation failed: providers is empty` +前者由 `config.ValidateSnapshot()` 保证,后者由 `internal/config/state.Service.EnsureSelection()` 保证。 -**解决**: -- 确保使用最新版本的代码 -- 删除 `~/.neocode/config.yaml` 让系统重新生成 +不要把这两层职责混在一起理解。 -### API Key 未找到 +## CLI Workdir 覆盖 -**错误**:`environment variable OPENAI_API_KEY is empty` +工作目录不写入 `config.yaml`,只通过启动参数覆盖: -**解决**: ```bash -# 检查环境变量 -echo $OPENAI_API_KEY +go run ./cmd/neocode --workdir /path/to/workspace ``` -### Provider 不存在 +说明: -**错误**:`provider not found: xxx` +- `--workdir` 只影响本次进程 +- 不会回写到 `config.yaml` +- 工具根目录与 session 隔离都会使用该工作区 -**解决**: -- 检查 provider 名称拼写 -- 使用 `/provider` 命令查看所有可用 provider +## 常见错误 -## 相关文档 +### 旧字段被拒绝 -- [添加新 Provider](./adding-providers.md) +如果在 `config.yaml` 中看到如下字段: -## Context Compact +- `workdir` +- `default_workdir` +- `providers` +- `provider_overrides` -以下配置用于控制手动上下文压缩行为。 +当前版本会直接报未知字段错误。处理方式是手动删除这些字段,而不是等待程序自动迁移。 -### 配置示例 +### API Key 未设置 -```yaml -context: - compact: - manual_strategy: keep_recent - manual_keep_recent_messages: 10 - max_summary_chars: 1200 - micro_compact_disabled: false -``` +报错示例: -### 字段说明 - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `context.compact.manual_strategy` | string | `keep_recent` | 手动 `/compact` 策略,可选 `keep_recent` / `full_replace` | -| `context.compact.manual_keep_recent_messages` | int | `10` | `keep_recent` 模式下保留最近 N 条消息;会按 tool call 与 tool result 的原子块整体保留 | -| `context.compact.max_summary_chars` | int | `1200` | compact summary 最大字符数 | -| `context.compact.micro_compact_disabled` | bool | `false` | 是否关闭默认启用的读时 micro compact;设为 `true` 可快速回退到仅 trim、不做旧工具结果清理 | - -新增工具默认会参与 micro compact;如果某个工具的历史结果必须保留,需要在 `internal/tools` 的工具实现中显式声明保留策略。 - -更多行为说明见 [context-compact.md](../context-compact.md)。 - -## CLI 工作区覆盖 - -NeoCode 支持在启动时通过 CLI 参数覆盖当前运行工作区: - -```bash -go run ./cmd/neocode --workdir /path/to/workspace +```text +config: environment variable OPENAI_API_KEY is empty ``` -补充说明: +处理方式:先在当前 shell 中设置对应环境变量,再启动 NeoCode。 + +## 相关文档 -- `--workdir` 只影响本次启动,不会持久化到 `config.yaml` -- 运行时工具根目录与 session 存储分桶都会使用该工作区 -- session 现按工作区隔离存储,不同工作区的历史会话默认互不可见 +- [添加 Provider](./adding-providers.md) +- [配置管理详细设计](../config-management-detail-design.md) +- [Context Compact](../context-compact.md) diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 63b3a51e..8b801758 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "neo-code/internal/config" + configstate "neo-code/internal/config/state" agentcontext "neo-code/internal/context" "neo-code/internal/provider/builtin" providercatalog "neo-code/internal/provider/catalog" @@ -41,7 +42,7 @@ type RuntimeBundle struct { Config config.Config ConfigManager *config.Manager Runtime agentruntime.Runtime - ProviderSelection *config.SelectionService + ProviderSelection *configstate.Service } // EnsureConsoleUTF8 负责在 Windows 控制台中尽量启用 UTF-8 编码。 @@ -70,7 +71,7 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er return RuntimeBundle{}, err } modelCatalogs := providercatalog.NewService(manager.BaseDir(), providerRegistry, nil) - providerSelection := config.NewSelectionService(manager, providerRegistry, modelCatalogs) + providerSelection := configstate.NewService(manager, providerRegistry, modelCatalogs) if _, err := providerSelection.EnsureSelection(ctx); err != nil { return RuntimeBundle{}, err } @@ -123,7 +124,7 @@ func NewProgram(ctx context.Context, opts BootstrapOptions) (*tea.Program, error // bootstrapDefaultConfig 负责计算本次启动应使用的默认配置快照。 func bootstrapDefaultConfig(opts BootstrapOptions) (*config.Config, error) { - defaultCfg := config.DefaultConfig() + defaultCfg := config.StaticDefaults() workdir := strings.TrimSpace(opts.Workdir) if workdir == "" { return defaultCfg, nil diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index 67407a4e..d32ac815 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -18,6 +18,7 @@ import ( "time" "neo-code/internal/config" + configstate "neo-code/internal/config/state" "neo-code/internal/tools" "neo-code/internal/tools/mcp" ) @@ -110,7 +111,7 @@ func TestBuildRuntimeRejectsUnsupportedSelectedProviderDriverOnStartup(t *testin } _, err := BuildRuntime(context.Background(), BootstrapOptions{}) - if !errors.Is(err, config.ErrDriverUnsupported) { + if !errors.Is(err, configstate.ErrDriverUnsupported) { t.Fatalf("expected ErrDriverUnsupported, got %v", err) } @@ -132,7 +133,7 @@ func TestBuildToolRegistryUsesWebFetchConfig(t *testing.T) { })) defer server.Close() - cfg := config.Default().Clone() + cfg := config.StaticDefaults().Clone() cfg.Workdir = t.TempDir() cfg.Tools.WebFetch.MaxResponseBytes = 4 @@ -174,7 +175,7 @@ func TestBuildMCPRegistryFromConfig(t *testing.T) { }, } - cfg := config.Default().Clone() + cfg := config.StaticDefaults().Clone() cfg.Workdir = t.TempDir() cfg.Tools.MCP.Servers = []config.MCPServerConfig{ { @@ -212,7 +213,7 @@ func TestBuildMCPRegistryFromConfig(t *testing.T) { func TestBuildMCPRegistryUnsupportedSource(t *testing.T) { t.Parallel() - cfg := config.Default().Clone() + cfg := config.StaticDefaults().Clone() cfg.Workdir = t.TempDir() cfg.Tools.MCP.Servers = []config.MCPServerConfig{ { @@ -241,7 +242,7 @@ func TestDefaultRegisterMCPStdioServerSuccess(t *testing.T) { t.Parallel() registry := mcp.NewRegistry() - cfg := config.Default().Clone() + cfg := config.StaticDefaults().Clone() wd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) @@ -285,7 +286,7 @@ func TestDefaultRegisterMCPStdioServerRefreshFailure(t *testing.T) { t.Parallel() registry := mcp.NewRegistry() - cfg := config.Default().Clone() + cfg := config.StaticDefaults().Clone() wd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) @@ -324,7 +325,7 @@ func TestDefaultRegisterMCPStdioServerRefreshFailure(t *testing.T) { func TestBuildToolRegistryIncludesMCPFromConfig(t *testing.T) { t.Parallel() - cfg := config.Default().Clone() + cfg := config.StaticDefaults().Clone() cfg.Workdir = t.TempDir() cfg.Tools.MCP.Servers = []config.MCPServerConfig{ { @@ -374,7 +375,7 @@ func TestBuildToolRegistryIncludesMCPFromConfig(t *testing.T) { func TestBuildToolRegistryReturnsMCPSourceError(t *testing.T) { t.Parallel() - cfg := config.Default().Clone() + cfg := config.StaticDefaults().Clone() cfg.Workdir = t.TempDir() cfg.Tools.MCP.Servers = []config.MCPServerConfig{ { @@ -465,7 +466,7 @@ func TestResolveMCPServerEnvValidationErrors(t *testing.T) { func TestBuildMCPRegistryNoEnabledServerReturnsNil(t *testing.T) { t.Parallel() - cfg := config.Default().Clone() + cfg := config.StaticDefaults().Clone() cfg.Workdir = t.TempDir() cfg.Tools.MCP.Servers = []config.MCPServerConfig{ {ID: "docs", Enabled: false, Source: "stdio"}, @@ -483,7 +484,7 @@ func TestBuildMCPRegistryNoEnabledServerReturnsNil(t *testing.T) { func TestBuildMCPRegistryRegisterError(t *testing.T) { t.Parallel() - cfg := config.Default().Clone() + cfg := config.StaticDefaults().Clone() cfg.Workdir = t.TempDir() cfg.Tools.MCP.Servers = []config.MCPServerConfig{ {ID: "docs", Enabled: true, Source: "stdio"}, @@ -504,7 +505,7 @@ func TestBuildMCPRegistryRegisterError(t *testing.T) { func TestBuildMCPRegistryRollbackRegisteredServersOnFailure(t *testing.T) { t.Parallel() - cfg := config.Default().Clone() + cfg := config.StaticDefaults().Clone() cfg.Workdir = t.TempDir() cfg.Tools.MCP.Servers = []config.MCPServerConfig{ {ID: "docs", Enabled: true, Source: "stdio"}, @@ -551,7 +552,7 @@ func TestRollbackMCPServersBoundaries(t *testing.T) { func TestInitialMCPRefreshTimeoutAndDurationConversion(t *testing.T) { t.Parallel() - cfg := config.Default().Clone() + cfg := config.StaticDefaults().Clone() cfg.ToolTimeoutSec = 1 timeout := initialMCPRefreshTimeout(cfg) if timeout < 5*time.Second { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5f3595e7..f2ae85eb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "context" + "errors" "os" "path/filepath" "strings" @@ -31,7 +32,7 @@ func testDefaultProviderConfig() ProviderConfig { } func testDefaultConfig() *Config { - cfg := Default() + cfg := StaticDefaults() defaultProvider := testDefaultProviderConfig() cfg.Providers = []ProviderConfig{defaultProvider} cfg.SelectedProvider = defaultProvider.Name @@ -39,6 +40,23 @@ func testDefaultConfig() *Config { return cfg } +func applyStaticDefaultsForTest(cfg *Config) { + if len(cfg.Providers) == 0 { + cfg.Providers = cloneProviders(testDefaultConfig().Providers) + } + cfg.applyStaticDefaults(*testDefaultConfig()) +} + +func selectedProviderConfigForTest(cfg *Config) (ProviderConfig, error) { + if cfg == nil { + return ProviderConfig{}, os.ErrInvalid + } + if strings.TrimSpace(cfg.SelectedProvider) == "" { + return ProviderConfig{}, os.ErrNotExist + } + return cfg.ProviderByName(cfg.SelectedProvider) +} + func TestParseConfigFormats(t *testing.T) { t.Parallel() @@ -66,7 +84,7 @@ tools: if cfg.CurrentModel != "gpt-5.4" { t.Fatalf("expected current model gpt-5.4, got %q", cfg.CurrentModel) } - provider, err := cfg.SelectedProviderConfig() + provider, err := selectedProviderConfigForTest(cfg) if err != nil { t.Fatalf("selected provider: %v", err) } @@ -153,7 +171,7 @@ providers: if err != nil { t.Fatalf("parseConfig() error = %v", err) } - cfg.ApplyDefaultsFrom(*testDefaultConfig()) + applyStaticDefaultsForTest(cfg) tt.assert(t, cfg) }) } @@ -219,14 +237,14 @@ func TestConfigMethodErrorPaths(t *testing.T) { t.Run("selected provider on nil config", func(t *testing.T) { var cfg *Config - _, err := cfg.SelectedProviderConfig() - if err == nil || !strings.Contains(err.Error(), "config is nil") { + _, err := selectedProviderConfigForTest(cfg) + if !errors.Is(err, os.ErrInvalid) { t.Fatalf("expected nil config error, got %v", err) } }) t.Run("provider lookup not found", func(t *testing.T) { - cfg := Default() + cfg := StaticDefaults() _, err := cfg.ProviderByName("missing-provider") if err == nil || !strings.Contains(err.Error(), "not found") { t.Fatalf("expected missing provider error, got %v", err) @@ -269,8 +287,8 @@ func TestManagerConcurrentAccess(t *testing.T) { if cfg.SelectedProvider == "" { t.Errorf("selected provider should never be empty") } - if _, err := cfg.SelectedProviderConfig(); err != nil { - t.Errorf("SelectedProviderConfig() error = %v", err) + if _, err := selectedProviderConfigForTest(&cfg); err != nil { + t.Errorf("selected provider error = %v", err) } model := models[(idx+j)%len(models)] if err := manager.Update(context.Background(), func(next *Config) error { @@ -291,13 +309,13 @@ func TestManagerConcurrentAccess(t *testing.T) { wg.Wait() finalConfig := manager.Get() - finalConfig.ApplyDefaultsFrom(*testDefaultConfig()) - if err := finalConfig.Validate(); err != nil { + applyStaticDefaultsForTest(&finalConfig) + if err := finalConfig.ValidateSnapshot(); err != nil { t.Fatalf("final config should validate, got %v", err) } } -func TestConfigApplyDefaultsFillsMissingFields(t *testing.T) { +func TestConfigApplyStaticDefaultsFillsRuntimeFields(t *testing.T) { t.Parallel() cfg := &Config{ @@ -311,20 +329,16 @@ func TestConfigApplyDefaultsFillsMissingFields(t *testing.T) { Workdir: ".", } - cfg.ApplyDefaultsFrom(*testDefaultConfig()) + applyStaticDefaultsForTest(cfg) - provider, err := cfg.SelectedProviderConfig() - if err != nil { - t.Fatalf("SelectedProviderConfig() error = %v", err) - } - if provider.BaseURL != testBaseURL { - t.Fatalf("expected default base url %q, got %q", testBaseURL, provider.BaseURL) + if cfg.CurrentModel != "" { + t.Fatalf("expected current model to stay empty, got %q", cfg.CurrentModel) } - if provider.APIKeyEnv != testAPIKeyEnv { - t.Fatalf("expected default api key env %q, got %q", testAPIKeyEnv, provider.APIKeyEnv) + if cfg.Providers[0].BaseURL != "" { + t.Fatalf("expected provider base url to stay empty, got %q", cfg.Providers[0].BaseURL) } - if cfg.CurrentModel != testModel { - t.Fatalf("expected current model %q, got %q", testModel, cfg.CurrentModel) + if cfg.Providers[0].APIKeyEnv != "" { + t.Fatalf("expected provider api key env to stay empty, got %q", cfg.Providers[0].APIKeyEnv) } if !filepath.IsAbs(cfg.Workdir) { t.Fatalf("expected absolute workdir, got %q", cfg.Workdir) @@ -341,7 +355,7 @@ func TestConfigValidateFailures(t *testing.T) { t.Parallel() validConfig := testDefaultConfig().Clone() - validConfig.ApplyDefaultsFrom(*testDefaultConfig()) + applyStaticDefaultsForTest(&validConfig) tests := []struct { name string @@ -476,7 +490,7 @@ func TestConfigValidateFailures(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := tt.config.Validate() + err := tt.config.ValidateSnapshot() if err == nil || !strings.Contains(err.Error(), tt.expectErr) { t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) } @@ -504,13 +518,35 @@ func TestConfigValidateAllowsEmptyCurrentModelForSelectedCustomProvider(t *testi Workdir: workdir, Shell: "powershell", } - cfg.ApplyDefaultsFrom(*testDefaultConfig()) + applyStaticDefaultsForTest(&cfg) - if err := cfg.Validate(); err != nil { + if err := cfg.ValidateSnapshot(); err != nil { t.Fatalf("expected selected custom provider with empty current_model to validate, got %v", err) } } +func TestValidateSnapshotDoesNotTreatSelectionAsRuntimeReady(t *testing.T) { + t.Parallel() + + cfg := Config{ + Providers: []ProviderConfig{ + testDefaultProviderConfig(), + }, + SelectedProvider: "missing-provider", + CurrentModel: "", + Workdir: filepath.Clean(t.TempDir()), + Shell: "powershell", + } + applyStaticDefaultsForTest(&cfg) + + if err := cfg.ValidateSnapshot(); err != nil { + t.Fatalf("expected snapshot validation to ignore unresolved selection state, got %v", err) + } + if _, err := ResolveSelectedProvider(cfg); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected unresolved selected provider after snapshot validation, got %v", err) + } +} + func TestMCPConfigApplyDefaultsAndClone(t *testing.T) { t.Parallel() @@ -644,9 +680,14 @@ func TestProviderLookupAndResolveSelectedProvider(t *testing.T) { t.Fatalf("expected provider %q, got %q", testProviderName, provider.Name) } - resolved, err := manager.ResolvedSelectedProvider() + current := manager.Get() + selected, err := current.ProviderByName(current.SelectedProvider) + if err != nil { + t.Fatalf("selected provider error = %v", err) + } + resolved, err := selected.Resolve() if err != nil { - t.Fatalf("ResolvedSelectedProvider() error = %v", err) + t.Fatalf("Resolve() error = %v", err) } if resolved.APIKey != "lookup-key" { t.Fatalf("expected resolved key %q, got %q", "lookup-key", resolved.APIKey) @@ -698,9 +739,9 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { if reloaded.CurrentModel != "gpt-5.4" { t.Fatalf("expected current model %q, got %q", "gpt-5.4", reloaded.CurrentModel) } - provider, err := reloaded.SelectedProviderConfig() + provider, err := selectedProviderConfigForTest(reloaded) if err != nil { - t.Fatalf("SelectedProviderConfig() reload error = %v", err) + t.Fatalf("selected provider reload error = %v", err) } if provider.Model != testModel { t.Fatalf("expected provider default model to stay %q, got %q", testModel, provider.Model) @@ -738,38 +779,38 @@ func TestLoaderUsesUpdatedBuiltinProviderWhenUserHasNoOverride(t *testing.T) { t.Fatalf("updated Load() error = %v", err) } - provider, err := reloaded.SelectedProviderConfig() + provider, err := selectedProviderConfigForTest(reloaded) if err != nil { - t.Fatalf("SelectedProviderConfig() error = %v", err) + t.Fatalf("selected provider error = %v", err) } if provider.BaseURL != "https://new.example/v1" { t.Fatalf("expected latest builtin base url, got %q", provider.BaseURL) } } -func TestApplyDefaultsPreservesCustomProvidersAlongsideBuiltinSnapshot(t *testing.T) { +func TestAssembleProvidersPreservesCustomProvidersAlongsideBuiltinSnapshot(t *testing.T) { t.Parallel() - current := Config{ - Providers: []ProviderConfig{{ - Name: "openai-alt", - Driver: "custom", - BaseURL: "https://example.com/v1", - APIKeyEnv: "CUSTOM_API_KEY", - Source: ProviderSourceCustom, - }}, - SelectedProvider: "openai-alt", - CurrentModel: "server-discovered-model", - } + customProviders := []ProviderConfig{{ + Name: "openai-alt", + Driver: "custom", + BaseURL: "https://example.com/v1", + APIKeyEnv: "CUSTOM_API_KEY", + Source: ProviderSourceCustom, + }} - current.ApplyDefaultsFrom(*testDefaultConfig()) + assembled, err := assembleProviders(testDefaultConfig().Providers, customProviders) + if err != nil { + t.Fatalf("assembleProviders() error = %v", err) + } - if len(current.Providers) != 2 { - t.Fatalf("expected builtin and custom providers to coexist, got %+v", current.Providers) + if len(assembled) != 2 { + t.Fatalf("expected builtin and custom providers to coexist, got %+v", assembled) } + current := Config{Providers: assembled} customProvider, err := current.ProviderByName("openai-alt") if err != nil { - t.Fatalf("expected custom provider to be preserved, got %+v", current.Providers) + t.Fatalf("expected custom provider to be preserved, got %+v", assembled) } if customProvider.Source != ProviderSourceCustom { t.Fatalf("expected custom provider source, got %+v", customProvider) @@ -784,74 +825,56 @@ func TestApplyDefaultsPreservesCustomProvidersAlongsideBuiltinSnapshot(t *testin if provider.Source != ProviderSourceBuiltin { t.Fatalf("expected builtin provider source, got %+v", provider) } - if current.SelectedProvider != "openai-alt" { - t.Fatalf("expected selected provider to stay on custom provider, got %q", current.SelectedProvider) - } - if current.CurrentModel != "server-discovered-model" { - t.Fatalf("expected current model to preserve discovered model, got %q", current.CurrentModel) - } } -func TestApplyDefaultsKeepsDuplicateCustomProviderNamesForValidation(t *testing.T) { +func TestAssembleProvidersRejectsDuplicateCustomProviderNames(t *testing.T) { t.Parallel() - current := Config{ - Providers: []ProviderConfig{ - { - Name: "company-gateway", - Driver: "openaicompat", - BaseURL: "https://example-a.com/v1", - APIKeyEnv: "COMPANY_GATEWAY_A_API_KEY", - Source: ProviderSourceCustom, - }, - { - Name: "company-gateway", - Driver: "openaicompat", - BaseURL: "https://example-b.com/v1", - APIKeyEnv: "COMPANY_GATEWAY_B_API_KEY", - Source: ProviderSourceCustom, - }, + customProviders := []ProviderConfig{ + { + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://example-a.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_A_API_KEY", + Source: ProviderSourceCustom, + }, + { + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://example-b.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_B_API_KEY", + Source: ProviderSourceCustom, }, - SelectedProvider: testProviderName, - CurrentModel: testModel, } - current.ApplyDefaultsFrom(*testDefaultConfig()) - - if err := current.Validate(); err == nil || !strings.Contains(err.Error(), "duplicate provider name") { + if _, err := assembleProviders(testDefaultConfig().Providers, customProviders); err == nil || !strings.Contains(err.Error(), "duplicate provider name") { t.Fatalf("expected duplicate custom provider name error, got %v", err) } } -func TestApplyDefaultsKeepsIdenticalDuplicateCustomProviderNamesForValidation(t *testing.T) { +func TestAssembleProvidersRejectsIdenticalDuplicateCustomProviderNames(t *testing.T) { t.Parallel() - current := Config{ - Providers: []ProviderConfig{ - { - Name: "company-gateway", - Driver: "openaicompat", - BaseURL: "https://example.com/v1", - APIKeyEnv: "COMPANY_GATEWAY_API_KEY", - APIStyle: "responses", - Source: ProviderSourceCustom, - }, - { - Name: "company-gateway", - Driver: "openaicompat", - BaseURL: "https://example.com/v1", - APIKeyEnv: "COMPANY_GATEWAY_API_KEY", - APIStyle: "responses", - Source: ProviderSourceCustom, - }, + customProviders := []ProviderConfig{ + { + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + APIStyle: "responses", + Source: ProviderSourceCustom, + }, + { + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + APIStyle: "responses", + Source: ProviderSourceCustom, }, - SelectedProvider: testProviderName, - CurrentModel: testModel, } - current.ApplyDefaultsFrom(*testDefaultConfig()) - - if err := current.Validate(); err == nil || !strings.Contains(err.Error(), "duplicate provider name") { + if _, err := assembleProviders(testDefaultConfig().Providers, customProviders); err == nil || !strings.Contains(err.Error(), "duplicate provider name") { t.Fatalf("expected duplicate custom provider name error, got %v", err) } } @@ -862,7 +885,7 @@ func TestApplyDefaultsPreservesDynamicCurrentModel(t *testing.T) { cfg := testDefaultConfig() cfg.CurrentModel = "server-discovered-model" - cfg.ApplyDefaultsFrom(*testDefaultConfig()) + applyStaticDefaultsForTest(cfg) if cfg.CurrentModel != "server-discovered-model" { t.Fatalf("expected dynamic current model to be preserved, got %q", cfg.CurrentModel) @@ -927,8 +950,8 @@ func TestNormalizeWorkdirAndClone(t *testing.T) { var nilConfig *Config clonedNil := nilConfig.Clone() - clonedNil.ApplyDefaultsFrom(*testDefaultConfig()) - if err := clonedNil.Validate(); err != nil { + applyStaticDefaultsForTest(&clonedNil) + if err := clonedNil.ValidateSnapshot(); err != nil { t.Fatalf("cloned nil config should validate, got %v", err) } @@ -1147,7 +1170,7 @@ func restoreEnv(t *testing.T, key string) { func TestAutoCompactConfigDefaults(t *testing.T) { t.Parallel() - cfg := Default() + cfg := StaticDefaults() if cfg.Context.AutoCompact.InputTokenThreshold != DefaultAutoCompactInputTokenThreshold { t.Fatalf("expected input_token_threshold=%d, got %d", diff --git a/internal/config/default_config.go b/internal/config/default_config.go deleted file mode 100644 index 54e320ec..00000000 --- a/internal/config/default_config.go +++ /dev/null @@ -1,14 +0,0 @@ -package config - -// DefaultConfig returns the application defaults with builtin provider metadata attached. -func DefaultConfig() *Config { - cfg := Default() - providers := DefaultProviders() - cfg.Providers = providers - if len(providers) == 0 { - return cfg - } - cfg.SelectedProvider = providers[0].Name - cfg.CurrentModel = providers[0].Model - return cfg -} diff --git a/internal/config/default_config_test.go b/internal/config/default_config_test.go deleted file mode 100644 index 061f8608..00000000 --- a/internal/config/default_config_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package config - -import "testing" - -func TestDefaultConfigIncludesBuiltinProviders(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - if len(cfg.Providers) != 4 { - t.Fatalf("expected 4 builtin providers, got %d", len(cfg.Providers)) - } - if cfg.Providers[0].Name != OpenAIName { - t.Fatalf("expected first provider %q, got %q", OpenAIName, cfg.Providers[0].Name) - } - if cfg.Providers[1].Name != GeminiName { - t.Fatalf("expected second provider %q, got %q", GeminiName, cfg.Providers[1].Name) - } - if cfg.Providers[2].Name != OpenLLName { - t.Fatalf("expected third provider %q, got %q", OpenLLName, cfg.Providers[2].Name) - } - if cfg.Providers[3].Name != QiniuName { - t.Fatalf("expected fourth provider %q, got %q", QiniuName, cfg.Providers[3].Name) - } - if cfg.SelectedProvider != OpenAIName { - t.Fatalf("expected selected provider %q, got %q", OpenAIName, cfg.SelectedProvider) - } - if cfg.CurrentModel != OpenAIDefaultModel { - t.Fatalf("expected current model %q, got %q", OpenAIDefaultModel, cfg.CurrentModel) - } -} - -func TestDefaultConfigValidates(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - cfg.ApplyDefaultsFrom(*cfg) - if err := cfg.Validate(); err != nil { - t.Fatalf("expected default config to validate, got %v", err) - } -} - -func TestDefaultConfigWorkdirIsAbsolute(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - cfg.ApplyDefaultsFrom(*cfg) - if cfg.Workdir == "" { - t.Fatal("expected workdir to be set") - } -} diff --git a/internal/config/defaults_test.go b/internal/config/defaults_test.go new file mode 100644 index 00000000..b58fb4ff --- /dev/null +++ b/internal/config/defaults_test.go @@ -0,0 +1,56 @@ +package config + +import "testing" + +func TestDefaultProvidersIncludesBuiltinProviders(t *testing.T) { + t.Parallel() + + providers := DefaultProviders() + if len(providers) != 4 { + t.Fatalf("expected 4 builtin providers, got %d", len(providers)) + } + if providers[0].Name != OpenAIName { + t.Fatalf("expected first provider %q, got %q", OpenAIName, providers[0].Name) + } + if providers[1].Name != GeminiName { + t.Fatalf("expected second provider %q, got %q", GeminiName, providers[1].Name) + } + if providers[2].Name != OpenLLName { + t.Fatalf("expected third provider %q, got %q", OpenLLName, providers[2].Name) + } + if providers[3].Name != QiniuName { + t.Fatalf("expected fourth provider %q, got %q", QiniuName, providers[3].Name) + } +} + +func TestLoaderDefaultsValidate(t *testing.T) { + t.Parallel() + + cfg := StaticDefaults() + cfg.Providers = DefaultProviders() + cfg.applyStaticDefaults(*StaticDefaults()) + if err := cfg.ValidateSnapshot(); err != nil { + t.Fatalf("expected loader defaults to validate, got %v", err) + } +} + +func TestStaticDefaultsAreOnlyStaticSkeleton(t *testing.T) { + t.Parallel() + + cfg := StaticDefaults() + cfg.applyStaticDefaults(*StaticDefaults()) + + if err := cfg.ValidateSnapshot(); err == nil { + t.Fatal("expected static defaults alone to be insufficient without assembled providers") + } +} + +func TestDefaultConfigWorkdirIsAbsolute(t *testing.T) { + t.Parallel() + + cfg := StaticDefaults() + cfg.applyStaticDefaults(*StaticDefaults()) + if cfg.Workdir == "" { + t.Fatal("expected workdir to be set") + } +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 5e240b0d..2c163f6e 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -22,8 +22,8 @@ type Loader struct { } type persistedConfig struct { - SelectedProvider string `yaml:"selected_provider"` - CurrentModel string `yaml:"current_model"` + SelectedProvider string `yaml:"selected_provider,omitempty"` + CurrentModel string `yaml:"current_model,omitempty"` Shell string `yaml:"shell"` MaxLoops int `yaml:"max_loops,omitempty"` ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` @@ -58,8 +58,11 @@ func NewLoader(baseDir string, defaults *Config) *Loader { } snapshot := defaults.Clone() - snapshot.ApplyDefaultsFrom(*Default()) - if err := snapshot.Validate(); err != nil { + if len(snapshot.Providers) == 0 { + snapshot.Providers = cloneProviders(DefaultProviders()) + } + snapshot.applyStaticDefaults(*StaticDefaults()) + if err := snapshot.ValidateSnapshot(); err != nil { panic(fmt.Sprintf("config: invalid loader defaults: %v", err)) } @@ -109,19 +112,13 @@ func (l *Loader) Load(ctx context.Context) (*Config, error) { if err != nil { return nil, err } - cfg.Providers = customProviders - cfg.ApplyDefaultsFrom(l.defaults) - if err := cfg.Validate(); err != nil { - return nil, err - } - needsRewrite, err := persistedConfigDiffers(data, *cfg) + cfg.Providers, err = assembleProviders(l.defaults.Providers, customProviders) if err != nil { return nil, err } - if needsRewrite { - if err := l.Save(ctx, cfg); err != nil { - return nil, err - } + cfg.applyStaticDefaults(l.defaults) + if err := cfg.ValidateSnapshot(); err != nil { + return nil, err } return cfg, nil } @@ -136,12 +133,11 @@ func (l *Loader) Save(ctx context.Context, cfg *Config) error { } snapshot := cfg.Clone() - providersSnapshot := cloneProviders(snapshot.Providers) - snapshot.ApplyDefaultsFrom(l.defaults) - if len(providersSnapshot) > 0 { - snapshot.Providers = providersSnapshot + if len(snapshot.Providers) == 0 { + snapshot.Providers = cloneProviders(l.defaults.Providers) } - if err := snapshot.Validate(); err != nil { + snapshot.applyStaticDefaults(l.defaults) + if err := snapshot.ValidateSnapshot(); err != nil { return err } @@ -166,7 +162,7 @@ func defaultBaseDir() string { } func parseConfig(data []byte) (*Config, error) { - return parseConfigWithContextDefaults(data, Default().Context) + return parseConfigWithContextDefaults(data, StaticDefaults().Context) } // parseConfigWithContextDefaults 负责在解析配置时注入上下文压缩相关默认值。 @@ -254,10 +250,43 @@ func fromPersistedContextConfig(file persistedContextConfig, defaults ContextCon return out } -func persistedConfigDiffers(data []byte, cfg Config) (bool, error) { - canonical, err := marshalPersistedConfig(cfg) - if err != nil { - return false, err +// assembleProviders 按来源组装运行时 provider 集合,并在发现重名时直接报错。 +func assembleProviders(builtin []ProviderConfig, custom []ProviderConfig) ([]ProviderConfig, error) { + assembled := make([]ProviderConfig, 0, len(builtin)+len(custom)) + seen := make(map[string]string, len(builtin)+len(custom)) + + appendProvider := func(provider ProviderConfig) error { + name := strings.TrimSpace(provider.Name) + key := normalizeProviderName(name) + if key == "" { + assembled = append(assembled, cloneProviderConfig(provider)) + return nil + } + if existing, exists := seen[key]; exists { + return fmt.Errorf("config: duplicate provider name %q for %q and %q", name, existing, name) + } + seen[key] = name + assembled = append(assembled, cloneProviderConfig(provider)) + return nil + } + + for _, provider := range builtin { + candidate := cloneProviderConfig(provider) + if candidate.Source == "" { + candidate.Source = ProviderSourceBuiltin + } + if err := appendProvider(candidate); err != nil { + return nil, err + } + } + for _, provider := range custom { + candidate := cloneProviderConfig(provider) + if candidate.Source == "" { + candidate.Source = ProviderSourceCustom + } + if err := appendProvider(candidate); err != nil { + return nil, err + } } - return !bytes.Equal(bytes.TrimSpace(data), bytes.TrimSpace(canonical)), nil + return assembled, nil } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 9d2755c1..7b06404c 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -136,7 +136,7 @@ providers: } } -func TestLoaderRewritesNormalizedSelectionStateOnLoad(t *testing.T) { +func TestLoaderPreservesSelectionStateOnLoad(t *testing.T) { t.Parallel() loader := NewLoader(t.TempDir(), testDefaultConfig()) @@ -156,27 +156,24 @@ shell: powershell if err != nil { t.Fatalf("Load() error = %v", err) } - if cfg.SelectedProvider != testProviderName { - t.Fatalf("expected selected provider %q, got %q", testProviderName, cfg.SelectedProvider) + if cfg.SelectedProvider != "missing-provider" { + t.Fatalf("expected selected provider to remain unchanged, got %q", cfg.SelectedProvider) } - if cfg.CurrentModel != testModel { - t.Fatalf("expected current model %q, got %q", testModel, cfg.CurrentModel) + if cfg.CurrentModel != "" { + t.Fatalf("expected current model to remain empty, got %q", cfg.CurrentModel) } - rewritten, err := os.ReadFile(loader.ConfigPath()) + persisted, err := os.ReadFile(loader.ConfigPath()) if err != nil { - t.Fatalf("read rewritten config: %v", err) + t.Fatalf("read config: %v", err) } - text := string(rewritten) - if !strings.Contains(text, "selected_provider: "+testProviderName) { - t.Fatalf("expected rewritten config to persist selected provider, got:\n%s", text) - } - if !strings.Contains(text, "current_model: "+testModel) { - t.Fatalf("expected rewritten config to persist current model, got:\n%s", text) + text := string(persisted) + if !strings.Contains(text, "selected_provider: missing-provider") { + t.Fatalf("expected config file to remain unchanged, got:\n%s", text) } } -func TestLoaderRewritesMissingCurrentModelOnLoad(t *testing.T) { +func TestLoaderPreservesMissingCurrentModelOnLoad(t *testing.T) { t.Parallel() loader := NewLoader(t.TempDir(), testDefaultConfig()) @@ -199,17 +196,17 @@ shell: powershell if cfg.SelectedProvider != testProviderName { t.Fatalf("expected selected provider %q, got %q", testProviderName, cfg.SelectedProvider) } - if cfg.CurrentModel != testModel { - t.Fatalf("expected current model %q, got %q", testModel, cfg.CurrentModel) + if cfg.CurrentModel != "" { + t.Fatalf("expected current model to remain empty, got %q", cfg.CurrentModel) } - rewritten, err := os.ReadFile(loader.ConfigPath()) + persisted, err := os.ReadFile(loader.ConfigPath()) if err != nil { - t.Fatalf("read rewritten config: %v", err) + t.Fatalf("read config: %v", err) } - text := string(rewritten) - if !strings.Contains(text, "current_model: "+testModel) { - t.Fatalf("expected rewritten config to persist current model, got:\n%s", text) + text := string(persisted) + if strings.Contains(text, "current_model:") { + t.Fatalf("expected config file to preserve missing current_model, got:\n%s", text) } } diff --git a/internal/config/manager.go b/internal/config/manager.go index d083652a..4837307d 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -72,13 +72,11 @@ func (m *Manager) Update(ctx context.Context, mutate func(*Config) error) error if err := mutate(&next); err != nil { return err } - - providersSnapshot := cloneProviders(next.Providers) - next.ApplyDefaultsFrom(m.loader.DefaultConfig()) - if len(providersSnapshot) > 0 { - next.Providers = providersSnapshot + if len(next.Providers) == 0 { + next.Providers = cloneProviders(m.loader.DefaultConfig().Providers) } - if err := next.Validate(); err != nil { + next.applyStaticDefaults(m.loader.DefaultConfig()) + if err := next.ValidateSnapshot(); err != nil { return err } if err := m.loader.Save(ctx, &next); err != nil { @@ -89,20 +87,6 @@ func (m *Manager) Update(ctx context.Context, mutate func(*Config) error) error return nil } -func (m *Manager) SelectedProvider() (ProviderConfig, error) { - cfg := m.Get() - return cfg.SelectedProviderConfig() -} - -func (m *Manager) ResolvedSelectedProvider() (ResolvedProviderConfig, error) { - provider, err := m.SelectedProvider() - if err != nil { - return ResolvedProviderConfig{}, err - } - - return provider.Resolve() -} - func (m *Manager) BaseDir() string { return m.loader.BaseDir() } diff --git a/internal/config/model.go b/internal/config/model.go index 480c854f..d9360a74 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -130,11 +130,13 @@ type MCPEnvVarConfig struct { ValueEnv string `yaml:"value_env,omitempty"` } +// DefaultWebFetchSupportedContentTypes 返回 WebFetch 允许抓取的默认内容类型列表副本。 func DefaultWebFetchSupportedContentTypes() []string { return append([]string(nil), defaultWebFetchSupportedContentTypes...) } -func Default() *Config { +// StaticDefaults 返回 config 层负责的静态默认值骨架,不包含 provider 装配和选择状态修复。 +func StaticDefaults() *Config { return &Config{ Workdir: DefaultWorkdir, Shell: defaultShell(), @@ -150,7 +152,7 @@ func Default() *Config { func (c *Config) Clone() Config { if c == nil { - return *Default() + return *StaticDefaults() } clone := *c @@ -160,32 +162,12 @@ func (c *Config) Clone() Config { return clone } -func (c *Config) ApplyDefaultsFrom(defaults Config) { +// applyStaticDefaults 仅补齐静态配置默认值,不处理 provider 装配或当前选择状态修复。 +func (c *Config) applyStaticDefaults(defaults Config) { if c == nil { return } - c.Providers = mergeProviders(defaults.Providers, c.Providers) - - fallbackProvider := defaultSelectedProviderName(c.Providers, defaults.SelectedProvider) - selectedReset := false - switch current := strings.TrimSpace(c.SelectedProvider); { - case current == "": - c.SelectedProvider = fallbackProvider - selectedReset = true - case !containsProviderName(c.Providers, current): - c.SelectedProvider = fallbackProvider - selectedReset = true - default: - c.SelectedProvider = current - } - if strings.TrimSpace(c.CurrentModel) == "" || selectedReset { - if selected, err := c.SelectedProviderConfig(); err == nil { - c.CurrentModel = strings.TrimSpace(selected.Model) - } else if strings.TrimSpace(defaults.CurrentModel) != "" { - c.CurrentModel = defaults.CurrentModel - } - } if strings.TrimSpace(c.Workdir) == "" { c.Workdir = defaults.Workdir } @@ -204,7 +186,8 @@ func (c *Config) ApplyDefaultsFrom(defaults Config) { c.Workdir = normalizeWorkdir(c.Workdir) } -func (c *Config) Validate() error { +// ValidateSnapshot 仅校验配置快照本身是否结构完整,不负责 selected_provider/current_model 的可运行性修正。 +func (c *Config) ValidateSnapshot() error { if c == nil { return errors.New("config: config is nil") } @@ -240,25 +223,12 @@ func (c *Config) Validate() error { seenEndpoints[identity.Key()] = provider.Name } - if strings.TrimSpace(c.SelectedProvider) == "" { - return errors.New("config: selected_provider is empty") - } - selected, err := c.SelectedProviderConfig() - if err != nil { - return err - } - if strings.TrimSpace(c.CurrentModel) == "" && selected.Source != ProviderSourceCustom { - return errors.New("config: current_model is empty") - } if strings.TrimSpace(c.Workdir) == "" { return errors.New("config: workdir is empty") } if !filepath.IsAbs(c.Workdir) { return fmt.Errorf("config: workdir must be absolute, got %q", c.Workdir) } - if selected.Source != ProviderSourceCustom && strings.TrimSpace(selected.Model) == "" { - return fmt.Errorf("config: selected provider %q has empty model", selected.Name) - } if err := c.Tools.Validate(); err != nil { return fmt.Errorf("config: tools: %w", err) } @@ -269,13 +239,6 @@ func (c *Config) Validate() error { return nil } -func (c *Config) SelectedProviderConfig() (ProviderConfig, error) { - if c == nil { - return ProviderConfig{}, errors.New("config: config is nil") - } - return c.ProviderByName(c.SelectedProvider) -} - func (c *Config) ProviderByName(name string) (ProviderConfig, error) { if c == nil { return ProviderConfig{}, errors.New("config: config is nil") @@ -291,6 +254,20 @@ func (c *Config) ProviderByName(name string) (ProviderConfig, error) { return ProviderConfig{}, fmt.Errorf("config: provider %q not found", name) } +// ResolveSelectedProvider 解析当前配置中选中的 provider,并补全运行时所需的密钥信息。 +func ResolveSelectedProvider(cfg Config) (ResolvedProviderConfig, error) { + providerName := strings.TrimSpace(cfg.SelectedProvider) + if providerName == "" { + return ResolvedProviderConfig{}, errors.New("config: selected provider is empty") + } + + providerCfg, err := cfg.ProviderByName(providerName) + if err != nil { + return ResolvedProviderConfig{}, err + } + return providerCfg.Resolve() +} + func (p ProviderConfig) Validate() error { if strings.TrimSpace(p.Name) == "" { return errors.New("provider name is empty") @@ -717,119 +694,6 @@ func cloneProviderConfig(provider ProviderConfig) ProviderConfig { return cloned } -// mergeProviders 将 builtin defaults 与当前运行时 providers 合并,保留自定义 provider, -// 并用 builtin 定义为同名 provider 补齐缺失字段。 -func mergeProviders(defaults []ProviderConfig, current []ProviderConfig) []ProviderConfig { - if len(defaults) == 0 && len(current) == 0 { - return nil - } - - merged := make([]ProviderConfig, 0, len(defaults)+len(current)) - indexByName := make(map[string]int, len(defaults)+len(current)) - - appendProvider := func(provider ProviderConfig) { - key := normalizeProviderName(provider.Name) - indexByName[key] = len(merged) - merged = append(merged, cloneProviderConfig(provider)) - } - - for _, provider := range defaults { - builtin := cloneProviderConfig(provider) - if builtin.Source == "" { - builtin.Source = ProviderSourceBuiltin - } - appendProvider(builtin) - } - - for _, provider := range current { - candidate := cloneProviderConfig(provider) - key := normalizeProviderName(candidate.Name) - if key == "" { - merged = append(merged, candidate) - continue - } - - if index, exists := indexByName[key]; exists { - if !shouldMergeProviderDefinition(merged[index], candidate) { - merged = append(merged, candidate) - continue - } - merged[index] = mergeProviderConfig(merged[index], candidate) - continue - } - - if candidate.Source == "" { - candidate.Source = ProviderSourceCustom - } - appendProvider(candidate) - } - - return merged -} - -// mergeProviderConfig 将 override 中的显式字段覆盖到 base 之上,用于 builtin 缺省值回填。 -func mergeProviderConfig(base ProviderConfig, override ProviderConfig) ProviderConfig { - merged := cloneProviderConfig(base) - - if strings.TrimSpace(override.Name) != "" { - merged.Name = strings.TrimSpace(override.Name) - } - if strings.TrimSpace(override.Driver) != "" { - merged.Driver = strings.TrimSpace(override.Driver) - } - if strings.TrimSpace(override.BaseURL) != "" { - merged.BaseURL = strings.TrimSpace(override.BaseURL) - } - if strings.TrimSpace(override.Model) != "" { - merged.Model = strings.TrimSpace(override.Model) - } - if strings.TrimSpace(override.APIKeyEnv) != "" { - merged.APIKeyEnv = strings.TrimSpace(override.APIKeyEnv) - } - if strings.TrimSpace(override.APIStyle) != "" { - merged.APIStyle = strings.TrimSpace(override.APIStyle) - } - if strings.TrimSpace(override.DeploymentMode) != "" { - merged.DeploymentMode = strings.TrimSpace(override.DeploymentMode) - } - if strings.TrimSpace(override.APIVersion) != "" { - merged.APIVersion = strings.TrimSpace(override.APIVersion) - } - if len(override.Models) > 0 { - merged.Models = providertypes.CloneModelDescriptors(override.Models) - } - if override.Source != "" { - merged.Source = override.Source - } - - return merged -} - -// shouldMergeProviderDefinition 判断同名 provider 是否允许做 builtin 默认值回填。 -// custom provider 的同名冲突必须保留下来,交给后续校验明确报错。 -func shouldMergeProviderDefinition(existing ProviderConfig, candidate ProviderConfig) bool { - if normalizeProviderName(existing.Name) != normalizeProviderName(candidate.Name) { - return false - } - if existing.Source == ProviderSourceBuiltin { - return candidate.Source != ProviderSourceCustom - } - if candidate.Source == ProviderSourceBuiltin { - return false - } - return false -} - -func defaultSelectedProviderName(providers []ProviderConfig, fallback string) string { - if containsProviderName(providers, fallback) { - return strings.TrimSpace(fallback) - } - if len(providers) == 0 { - return "" - } - return strings.TrimSpace(providers[0].Name) -} - func containsProviderName(providers []ProviderConfig, name string) bool { target := normalizeProviderName(name) if target == "" { diff --git a/internal/config/model_test.go b/internal/config/model_test.go new file mode 100644 index 00000000..4dc3ba3d --- /dev/null +++ b/internal/config/model_test.go @@ -0,0 +1,1069 @@ +package config + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + providerpkg "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" +) + +// ---- 辅助函数与默认值测试 ---- + +func TestNormalizeWorkdirEdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + check func(t *testing.T, value string) + }{ + { + name: "empty string returns empty", + input: "", + check: func(t *testing.T, value string) { + if value != "" { + t.Fatalf("expected empty, got %q", value) + } + }, + }, + { + name: "whitespace only returns empty", + input: " ", + check: func(t *testing.T, value string) { + if value != "" { + t.Fatalf("expected empty for whitespace-only input, got %q", value) + } + }, + }, + { + name: "dot resolves to cwd", + input: ".", + check: func(t *testing.T, value string) { + if !filepath.IsAbs(value) { + t.Fatalf("expected absolute path for dot, got %q", value) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := normalizeWorkdir(tt.input) + tt.check(t, got) + }) + } +} + +func TestContainsProviderName(t *testing.T) { + t.Parallel() + + providers := []ProviderConfig{ + {Name: "OpenAI"}, + {Name: "Gemini"}, + } + if !containsProviderName(providers, "openai") { + t.Fatal("expected openai to be found (case insensitive)") + } + if !containsProviderName(providers, "OPENAI") { + t.Fatal("expected OPENAI to be found") + } + if containsProviderName(providers, "Anthropic") { + t.Fatal("expected Anthropic to not be found") + } + if containsProviderName(nil, "openai") { + t.Fatal("expected nil providers to return false") + } + if containsProviderName(providers, "") { + t.Fatal("expected empty name to return false") + } + if containsProviderName(providers, " ") { + t.Fatal("expected whitespace name to return false") + } +} + +func TestDefaultWebFetchSupportedContentTypesReturnsCopy(t *testing.T) { + t.Parallel() + + types1 := DefaultWebFetchSupportedContentTypes() + types2 := DefaultWebFetchSupportedContentTypes() + if &types1[0] == &types2[0] { + t.Fatal("expected independent slices") + } + if len(types1) == 0 { + t.Fatal("expected non-empty default content types") + } +} + +func TestStaticReturnsCompleteSkeleton(t *testing.T) { + t.Parallel() + + cfg := StaticDefaults() + if cfg == nil { + t.Fatal("expected non-nil static defaults") + } + if cfg.Workdir == "" { + t.Fatal("expected workdir to be set") + } + if cfg.Shell == "" { + t.Fatal("expected shell to be set") + } + if cfg.MaxLoops == 0 { + t.Fatal("expected max_loops to be set") + } + if cfg.ToolTimeoutSec == 0 { + t.Fatal("expected tool_timeout_sec to be set") + } + if cfg.Tools.WebFetch.MaxResponseBytes == 0 { + t.Fatal("expected webfetch max_response_bytes to be set") + } +} + +// ---- Clone 独立性测试 ---- + +func TestWebFetchConfigCloneIndependence(t *testing.T) { + t.Parallel() + + original := WebFetchConfig{ + MaxResponseBytes: 4096, + SupportedContentTypes: []string{"text/html", "application/json"}, + } + cloned := original.Clone() + + cloned.MaxResponseBytes = 1024 + cloned.SupportedContentTypes[0] = "text/plain" + + if original.MaxResponseBytes == cloned.MaxResponseBytes { + t.Fatal("expected MaxResponseBytes clone to be independent") + } + if original.SupportedContentTypes[0] == cloned.SupportedContentTypes[0] { + t.Fatal("expected SupportedContentTypes clone to be independent") + } +} + +func TestContextConfigCloneIndependence(t *testing.T) { + t.Parallel() + + original := ContextConfig{ + Compact: CompactConfig{ + ManualStrategy: CompactManualStrategyKeepRecent, + ManualKeepRecentMessages: 10, + MaxSummaryChars: 1200, + }, + AutoCompact: AutoCompactConfig{ + Enabled: true, + InputTokenThreshold: 50000, + }, + } + cloned := original.Clone() + + cloned.Compact.ManualStrategy = CompactManualStrategyFullReplace + cloned.Compact.ManualKeepRecentMessages = 5 + cloned.AutoCompact.Enabled = false + cloned.AutoCompact.InputTokenThreshold = 100000 + + if original.Compact.ManualStrategy == cloned.Compact.ManualStrategy { + t.Fatal("expected Compact Clone to be independent") + } + if original.Compact.ManualKeepRecentMessages == cloned.Compact.ManualKeepRecentMessages { + t.Fatal("expected ManualKeepRecentMessages Clone to be independent") + } + if original.AutoCompact.Enabled == cloned.AutoCompact.Enabled { + t.Fatal("expected AutoCompact Enabled clone to be independent") + } + if original.AutoCompact.InputTokenThreshold == cloned.AutoCompact.InputTokenThreshold { + t.Fatal("expected AutoCompact InputTokenThreshold clone to be independent") + } +} + +func TestCompactConfigCloneValueSemantics(t *testing.T) { + t.Parallel() + + original := CompactConfig{ + ManualStrategy: CompactManualStrategyFullReplace, + ManualKeepRecentMessages: 5, + MaxSummaryChars: 800, + MicroCompactDisabled: true, + } + cloned := original.Clone() + if original != cloned { + t.Fatalf("expected equal configs, got %+v vs %+v", original, cloned) + } +} + +func TestAutoCompactConfigCloneValueSemantics(t *testing.T) { + t.Parallel() + + original := AutoCompactConfig{Enabled: true, InputTokenThreshold: 75000} + cloned := original.Clone() + if original != cloned { + t.Fatalf("expected equal configs, got %+v vs %+v", original, cloned) + } +} + +// ---- nil 接收者 ApplyDefaults 测试 ---- + +func TestApplyDefaultsNilReceivers(t *testing.T) { + t.Parallel() + + var toolsCfg *ToolsConfig + toolsCfg.ApplyDefaults(ToolsConfig{}) + // 不应 panic + + var ctxCfg *ContextConfig + ctxCfg.ApplyDefaults(ContextConfig{}) + // 不应 panic + + var mcpCfg *MCPConfig + mcpCfg.ApplyDefaults(MCPConfig{}) + // 不应 panic + + var wfCfg *WebFetchConfig + wfCfg.ApplyDefaults(WebFetchConfig{}) + // 不应 panic + + var ccfg *CompactConfig + ccfg.ApplyDefaults(CompactConfig{}) + // 不应 panic + + var serverCfg *MCPServerConfig + serverCfg.ApplyDefaults() + // 不应 panic +} + +func TestApplyStaticDefaultsNilReceiver(t *testing.T) { + t.Parallel() + + var cfg *Config + cfg.applyStaticDefaults(*StaticDefaults()) + // 不应 panic,cfg 应仍为 nil + if cfg != nil { + t.Fatal("expected nil config to remain nil") + } +} + +// ---- 校验边界测试 ---- + +func TestValidateSnapshotRejectsEmptyWorkdir(t *testing.T) { + t.Parallel() + + cfg := Config{ + Providers: []ProviderConfig{testDefaultProviderConfig()}, + SelectedProvider: testProviderName, + CurrentModel: testModel, + Workdir: "", + Shell: "powershell", + } + err := cfg.ValidateSnapshot() + if err == nil || !strings.Contains(err.Error(), "workdir is empty") { + t.Fatalf("expected empty workdir error, got %v", err) + } +} + +func TestValidateSnapshotPropagatesCompactError(t *testing.T) { + t.Parallel() + + workdir := filepath.Clean(t.TempDir()) + cfg := Config{ + Providers: []ProviderConfig{testDefaultProviderConfig()}, + SelectedProvider: testProviderName, + CurrentModel: testModel, + Workdir: workdir, + Shell: "powershell", + Tools: ToolsConfig{ + WebFetch: WebFetchConfig{ + MaxResponseBytes: DefaultWebFetchMaxResponseBytes, + SupportedContentTypes: []string{"text/html"}, + }, + }, + Context: ContextConfig{ + Compact: CompactConfig{ + ManualStrategy: "invalid_strategy", + ManualKeepRecentMessages: 10, + MaxSummaryChars: 1200, + }, + }, + } + err := cfg.ValidateSnapshot() + if err == nil || !strings.Contains(err.Error(), "compact") { + t.Fatalf("expected compact error, got %v", err) + } +} + +func TestResolveSelectedProviderEmptyString(t *testing.T) { + t.Parallel() + + cfg := Config{ + SelectedProvider: "", + Providers: []ProviderConfig{testDefaultProviderConfig()}, + } + _, err := ResolveSelectedProvider(cfg) + if err == nil || !strings.Contains(err.Error(), "selected provider is empty") { + t.Fatalf("expected empty selected provider error, got %v", err) + } +} + +func TestResolveSelectedProviderNotFound(t *testing.T) { + t.Parallel() + + cfg := Config{ + SelectedProvider: "nonexistent-provider", + Providers: []ProviderConfig{testDefaultProviderConfig()}, + } + _, err := ResolveSelectedProvider(cfg) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected not found error, got %v", err) + } +} + +func TestProviderConfigIdentity(t *testing.T) { + t.Parallel() + + cfg := ProviderConfig{ + Name: "test-openai", + Driver: "openaicompat", + BaseURL: "https://api.openai.com/v1", + Model: "gpt-4o", + APIKeyEnv: "TEST_KEY", + APIStyle: "chat_completions", + } + + identity, err := cfg.Identity() + if err != nil { + t.Fatalf("Identity() error = %v", err) + } + if identity.Driver != "openaicompat" { + t.Fatalf("expected driver openaicompat, got %q", identity.Driver) + } + if identity.BaseURL != "https://api.openai.com/v1" { + t.Fatalf("expected base URL, got %q", identity.BaseURL) + } + if identity.APIStyle != "chat_completions" { + t.Fatalf("expected api_style chat_completions, got %q", identity.APIStyle) + } +} + +func TestProviderConfigResolveAPIKeyEmptyEnvName(t *testing.T) { + t.Parallel() + + cfg := ProviderConfig{Name: "test", APIKeyEnv: ""} + _, err := cfg.ResolveAPIKey() + if err == nil || !strings.Contains(err.Error(), "api_key_env is empty") { + t.Fatalf("expected empty api_key_env error, got %v", err) + } +} + +// ---- 内容类型规范化测试 ---- + +func TestNormalizeContentType(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + expected string + }{ + {input: "text/html", expected: "text/html"}, + {input: " Text/HTML ", expected: "text/html"}, + {input: "text/html; charset=utf-8", expected: "text/html"}, + {input: "APPLICATION/JSON", expected: "application/json"}, + {input: " ", expected: ""}, + {input: "", expected: ""}, + {input: "application/x-www-form-urlencoded; charset=utf-8", expected: "application/x-www-form-urlencoded"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + got := normalizeContentType(tt.input) + if got != tt.expected { + t.Fatalf("normalizeContentType(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestNormalizeContentTypesDeduplicatesAndCleans(t *testing.T) { + t.Parallel() + + defaults := []string{"text/html", "text/plain"} + result := normalizeContentTypes([]string{"TEXT/HTML", " text/plain ", "text/html", "", " "}, defaults) + if len(result) != 2 { + t.Fatalf("expected 2 unique content types, got %d: %+v", len(result), result) + } +} + +func TestNormalizeContentTypesFallsBackToDefaultsWhenEmpty(t *testing.T) { + t.Parallel() + + defaults := []string{"text/html", "text/plain"} + // 当所有输入值都为空或纯空白时,它们全部被过滤掉,结果为空切片 + // (normalizeContentTypes 只在 source 长度为 0 时才回退到 defaults,而这里 source 长度为 2) + result := normalizeContentTypes([]string{"", " "}, defaults) + if len(result) != 0 { + t.Fatalf("expected 0 types when all inputs are empty, got %d: %+v", len(result), result) + } +} + +// ---- Manager 边界测试 ---- + +func TestManagerUpdateNilMutateFunc(t *testing.T) { + tempDir := t.TempDir() + manager := NewManager(NewLoader(tempDir, testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + err := manager.Update(context.Background(), nil) + if err == nil || !strings.Contains(err.Error(), "mutate func is nil") { + t.Fatalf("expected nil mutate error, got %v", err) + } +} + +func TestManagerUpdateValidationFailurePreservesCurrentState(t *testing.T) { + tempDir := t.TempDir() + manager := NewManager(NewLoader(tempDir, testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + before := manager.Get() + // 使用无效的 compact strategy 触发验证失败(applyStaticDefaults 不会修复这个字段) + err := manager.Update(context.Background(), func(cfg *Config) error { + cfg.Context.Compact.ManualStrategy = "totally_invalid_strategy" + return nil + }) + if err == nil { + t.Fatal("expected validation error") + } + + after := manager.Get() + if before.SelectedProvider != after.SelectedProvider { + t.Fatalf("expected selected provider to be preserved after failed update") + } +} + +// ---- Loader 边界测试 ---- + +func TestParseConfigWithEmptyData(t *testing.T) { + t.Parallel() + + cfg, err := parseConfigWithContextDefaults([]byte{}, StaticDefaults().Context) + if err != nil { + t.Fatalf("parseConfigWithContextDefaults(empty) error = %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config for empty data") + } +} + +func TestParseConfigWithWhitespaceOnlyData(t *testing.T) { + t.Parallel() + + cfg, err := parseConfigWithContextDefaults([]byte(" \n\t "), StaticDefaults().Context) + if err != nil { + t.Fatalf("parseConfigWithContextDefaults(whitespace) error = %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config for whitespace-only data") + } +} + +func TestMarshalPersistedConfigEndsWithNewline(t *testing.T) { + t.Parallel() + + snapshot := testDefaultConfig().Clone() + data, err := marshalPersistedConfig(snapshot) + if err != nil { + t.Fatalf("marshalPersistedConfig() error = %v", err) + } + if len(data) == 0 { + t.Fatal("expected non-empty marshaled data") + } + if data[len(data)-1] != '\n' { + t.Fatal("expected marshaled data to end with newline") + } +} + +func TestAssembleProvidersAcceptsEmptyNameProvider(t *testing.T) { + t.Parallel() + + custom := []ProviderConfig{{ + Name: "", + Driver: "custom-driver", + BaseURL: "https://example.com/v1", + APIKeyEnv: "CUSTOM_KEY", + Source: ProviderSourceCustom, + }} + + assembled, err := assembleProviders(testDefaultConfig().Providers, custom) + if err != nil { + t.Fatalf("assembleProviders() with empty name error = %v", err) + } + if len(assembled) < 2 { + t.Fatalf("expected at least 2 providers, got %d", len(assembled)) + } +} + +func TestAssembleProvidersDuplicateBetweenBuiltinAndCustom(t *testing.T) { + t.Parallel() + + custom := []ProviderConfig{{ + Name: testProviderName, // 与内置同名 + Driver: "custom-driver", + BaseURL: "https://example.com/v1", + APIKeyEnv: "CUSTOM_KEY", + Source: ProviderSourceCustom, + }} + + _, err := assembleProviders(testDefaultConfig().Providers, custom) + if err == nil || !strings.Contains(err.Error(), "duplicate provider name") { + t.Fatalf("expected duplicate error between builtin/custom, got %v", err) + } +} + +// ---- runtime 转换测试 ---- + +func TestToRuntimeConfigMapsAllFields(t *testing.T) { + t.Parallel() + + resolved := ResolvedProviderConfig{ + ProviderConfig: ProviderConfig{ + Name: "test-provider", + Driver: "gemini", + BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + Model: "gemini-2.5-flash", + APIKeyEnv: "TEST_ENV_KEY", + APIStyle: "responses", + DeploymentMode: "vertex", + APIVersion: "v1beta", + }, + APIKey: "resolved-secret-key", + } + + got := resolved.ToRuntimeConfig() + if got.Name != "test-provider" { + t.Fatalf("expected Name=test-provider, got %q", got.Name) + } + if got.Driver != "gemini" { + t.Fatalf("expected Driver=gemini, got %q", got.Driver) + } + if got.DefaultModel != "gemini-2.5-flash" { + t.Fatalf("expected DefaultModel=gemini-2.5-flash, got %q", got.DefaultModel) + } + if got.APIKey != "resolved-secret-key" { + t.Fatalf("expected APIKey=resolved-secret-key, got %q", got.APIKey) + } + if got.DeploymentMode != "vertex" { + t.Fatalf("expected DeploymentMode=vertex, got %q", got.DeploymentMode) + } + if got.APIVersion != "v1beta" { + t.Fatalf("expected APIVersion=v1beta, got %q", got.APIVersion) + } +} + +// ---- MCP 配置测试 ---- + +func TestMCPServerConfigApplyDefaultsNormalizesSource(t *testing.T) { + t.Parallel() + + cfg := MCPServerConfig{ + ID: "test-server", + Enabled: true, + Source: " STDIO ", + Stdio: MCPStdioConfig{Command: "cmd"}, + } + cfg.ApplyDefaults() + if cfg.Source != "stdio" { + t.Fatalf("expected normalized source 'stdio', got %q", cfg.Source) + } +} + +func TestMCPServerConfigApplyDefaultsSetsStdioWhenEmpty(t *testing.T) { + t.Parallel() + + cfg := MCPServerConfig{ + ID: "test-server", + Enabled: true, + Source: "", + Stdio: MCPStdioConfig{Command: "cmd"}, + } + cfg.ApplyDefaults() + if cfg.Source != "stdio" { + t.Fatalf("expected default source 'stdio', got %q", cfg.Source) + } +} + +func TestMCPConfigValidateSkipsDisabledServerWithoutCommand(t *testing.T) { + t.Parallel() + + cfg := MCPConfig{ + Servers: []MCPServerConfig{ + {ID: "disabled-no-cmd", Enabled: false, Source: "stdio"}, + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("expected disabled server without command to pass validation, got %v", err) + } +} + +func TestMCPConfigValidateRejectsServerWithEmptyEnvBothValues(t *testing.T) { + t.Parallel() + + cfg := MCPConfig{ + Servers: []MCPServerConfig{ + { + ID: "test", + Enabled: true, + Source: "stdio", + Stdio: MCPStdioConfig{Command: "cmd"}, + Env: []MCPEnvVarConfig{ + {Name: "VAR", Value: "", ValueEnv: ""}, + {Name: "VAR2", Value: "a", ValueEnv: "b"}, + }, + }, + }, + } + err := cfg.Validate() + // 至少应该有一个错误(两个 env 都有问题) + if err == nil { + t.Fatal("expected validation error for invalid env bindings") + } +} + +func TestMCPConfigValidateRejectsServerWithMissingEnvName(t *testing.T) { + t.Parallel() + + cfg := MCPConfig{ + Servers: []MCPServerConfig{ + { + ID: "test", + Enabled: true, + Source: "stdio", + Stdio: MCPStdioConfig{Command: "cmd"}, + Env: []MCPEnvVarConfig{ + {Name: " ", Value: "a"}, + }, + }, + }, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "name is empty") { + t.Fatalf("expected empty env name error, got %v", err) + } +} + +// ---- WebFetch 配置测试 ---- + +func TestWebFetchConfigValidateRejectsEmptyContentType(t *testing.T) { + t.Parallel() + + cfg := WebFetchConfig{ + MaxResponseBytes: 1024, + SupportedContentTypes: []string{"text/html", "", "application/json"}, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "[1] is empty") { + t.Fatalf("expected empty content type error, got %v", err) + } +} + +// ---- ContextConfig 验证传播测试 ---- + +func TestContextConfigValidatePropagatesCompactError(t *testing.T) { + t.Parallel() + + cfg := ContextConfig{ + Compact: CompactConfig{ + ManualKeepRecentMessages: 0, + MaxSummaryChars: 0, + }, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "compact") { + t.Fatalf("expected compact validation error, got %v", err) + } +} + +// ---- ProviderIdentityFromConfig 默认 APIStyle 回退 ---- + +func TestProviderIdentityFromConfigDefaultsAPIStyleForOpenAICompat(t *testing.T) { + t.Parallel() + + cfg := ProviderConfig{ + Name: "test-openai", + Driver: "openaicompat", + BaseURL: "https://api.openai.com/v1", + APIKeyEnv: "TEST_KEY", + // APIStyle 故意为空 + } + + identity, err := providerIdentityFromConfig(cfg) + if err != nil { + t.Fatalf("providerIdentityFromConfig() error = %v", err) + } + if identity.APIStyle != providerpkg.OpenAICompatibleAPIStyleChatCompletions { + t.Fatalf("expected default api_style %q, got %q", providerpkg.OpenAICompatibleAPIStyleChatCompletions, identity.APIStyle) + } +} + +// ---- Qiniu 内置 provider 完整性 ---- + +func TestQiniuProviderConfig(t *testing.T) { + t.Parallel() + + provider := QiniuProvider() + if provider.Name != QiniuName { + t.Fatalf("expected name %q, got %q", QiniuName, provider.Name) + } + if provider.Driver != "openaicompat" { + t.Fatalf("expected driver openaicompat, got %q", provider.Driver) + } + if provider.BaseURL != QiniuDefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", QiniuDefaultBaseURL, provider.BaseURL) + } + if provider.Model != QiniuDefaultModel { + t.Fatalf("expected default model %q, got %q", QiniuDefaultModel, provider.Model) + } + if provider.APIKeyEnv != QiniuDefaultAPIKeyEnv { + t.Fatalf("expected API key env %q, got %q", QiniuDefaultAPIKeyEnv, provider.APIKeyEnv) + } + if provider.Source != ProviderSourceBuiltin { + t.Fatalf("expected builtin source, got %q", provider.Source) + } +} + +// ---- normalizeConfigKey / normalizeProviderDriver 基本覆盖 ---- + +func TestNormalizeConfigKey(t *testing.T) { + t.Parallel() + + if normalizeConfigKey(" OpenAI ") != "openai" { + t.Fatal("expected normalized lowercase trimmed key") + } + if normalizeConfigKey("") != "" { + t.Fatal("expected empty key to remain empty") + } +} + +func TestNormalizeProviderDriver(t *testing.T) { + t.Parallel() + + if normalizeProviderDriver(" OPENAICOMPAT ") != "openaicompat" { + t.Fatal("expected normalized driver") + } + if normalizeProviderDriver("") != "" { + t.Fatal("expected empty driver to remain empty") + } +} + +// ---- MCPServerConfig Clone 独立性(无 env 场景)---- + +func TestMCPServerConfigCloneWithoutEnv(t *testing.T) { + t.Parallel() + + original := MCPServerConfig{ + ID: "test", + Enabled: true, + Source: "stdio", + Stdio: MCPStdioConfig{Command: "cmd", Args: []string{"--help"}}, + Env: nil, + } + cloned := original.Clone() + cloned.Stdio.Args[0] = "--version" + if original.Stdio.Args[0] == cloned.Stdio.Args[0] { + t.Fatal("expected args clone to be independent") + } +} + +func TestMCPServerConfigCloneWithEmptyEnvSlice(t *testing.T) { + t.Parallel() + + original := MCPServerConfig{ + ID: "test", + Stdio: MCPStdioConfig{Command: "cmd"}, + Env: []MCPEnvVarConfig{}, + } + cloned := original.Clone() + if len(cloned.Env) != 0 { + t.Fatal("expected cloned env to be empty slice, not nil") + } +} + +// ---- ProviderConfig Resolve 错误链 ---- + +func TestProviderConfigResolveWrapsAPIKeyError(t *testing.T) { + restoreEnv(t, "UNRESOLVABLE_API_KEY_FOR_TEST") + _ = os.Unsetenv("UNRESOLVABLE_API_KEY_FOR_TEST") + + cfg := ProviderConfig{ + Name: "test", + Driver: "custom", + BaseURL: "https://example.com", + APIKeyEnv: "UNRESOLVABLE_API_KEY_FOR_TEST", + } + _, err := cfg.Resolve() + if err == nil || !strings.Contains(err.Error(), "UNRESOLVABLE_API_KEY_FOR_TEST") { + t.Fatalf("expected unresolved API key error, got %v", err) + } +} + +// ---- ToolsConfig Clone + ApplyDefaults 完整性 ---- + +func TestToolsConfigCloneIndependence(t *testing.T) { + t.Parallel() + + original := ToolsConfig{ + WebFetch: WebFetchConfig{ + MaxResponseBytes: 2048, + SupportedContentTypes: []string{"text/html"}, + }, + MCP: MCPConfig{ + Servers: []MCPServerConfig{{ID: "s1"}}, + }, + } + cloned := original.Clone() + cloned.WebFetch.MaxResponseBytes = 999 + cloned.MCP.Servers[0].ID = "s2" + + if original.WebFetch.MaxResponseBytes == cloned.WebFetch.MaxResponseBytes { + t.Fatal("expected WebFetch clone independence") + } + if original.MCP.Servers[0].ID == cloned.MCP.Servers[0].ID { + t.Fatal("expected MCP clone independence") + } +} + +// ---- cloneProviderConfig 模型独立性 ---- + +func TestCloneProviderConfigModelDescriptorsIndependence(t *testing.T) { + t.Parallel() + + original := ProviderConfig{ + Name: "test", + Models: []providertypes.ModelDescriptor{{ID: "model-a"}, {ID: "model-b"}}, + } + cloned := cloneProviderConfig(original) + cloned.Models[0].ID = "mutated" + if original.Models[0].ID == cloned.Models[0].ID { + t.Fatal("expected Models descriptor clone to be independent") + } +} + +// ---- ProviderByName 大小写不敏感查找 ---- + +func TestProviderByNameCaseInsensitive(t *testing.T) { + t.Parallel() + + cfg := &Config{Providers: []ProviderConfig{testDefaultProviderConfig()}} + found, err := cfg.ProviderByName("OPENAI") + if err != nil { + t.Fatalf("ProviderByName(OPENAI) error = %v", err) + } + if found.Name != testProviderName { + t.Fatalf("expected provider %q, got %q", testProviderName, found.Name) + } +} + +// ---- ResolveSelectedProvider 包装 ProviderByName 错误 ---- + +func TestResolveSelectedProviderWrapsNotFoundError(t *testing.T) { + t.Parallel() + + cfg := Config{ + SelectedProvider: "nonexistent", + Providers: []ProviderConfig{testDefaultProviderConfig()}, + } + _, err := ResolveSelectedProvider(cfg) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected wrapped not found error, got %v", err) + } +} + +// ---- ContextConfig ApplyDefaults 传播到子配置 ---- + +func TestContextConfigApplyDefaultsNilReceiver(t *testing.T) { + t.Parallel() + + var ctxCfg *ContextConfig + ctxCfg.ApplyDefaults(ContextConfig{ + Compact: CompactConfig{ManualStrategy: CompactManualStrategyFullReplace}, + AutoCompact: AutoCompactConfig{InputTokenThreshold: 50000}, + }) + // 不应 panic +} + +// ---- CompactConfig ApplyDefaults 全部为零值时填充 ---- + +func TestCompactConfigApplyDefaultsAllZeroValues(t *testing.T) { + t.Parallel() + + cfg := CompactConfig{} + defaults := CompactConfig{ + ManualStrategy: CompactManualStrategyFullReplace, + ManualKeepRecentMessages: 15, + MaxSummaryChars: 2000, + } + cfg.ApplyDefaults(defaults) + if cfg.ManualStrategy != CompactManualStrategyFullReplace { + t.Fatalf("expected strategy %q, got %q", CompactManualStrategyFullReplace, cfg.ManualStrategy) + } + if cfg.ManualKeepRecentMessages != 15 { + t.Fatalf("expected messages=15, got %d", cfg.ManualKeepRecentMessages) + } + if cfg.MaxSummaryChars != 2000 { + t.Fatalf("expected chars=2000, got %d", cfg.MaxSummaryChars) + } +} + +// ---- WebFetchConfig ApplyDefaults 边界 ---- + +func TestWebFetchConfigApplyDefaultsNilReceiver(t *testing.T) { + t.Parallel() + + var wfCfg *WebFetchConfig + wfCfg.ApplyDefaults(WebFetchConfig{ + MaxResponseBytes: 512, + SupportedContentTypes: []string{"text/plain"}, + }) + // 不应 panic +} + +func TestWebFetchConfigApplyDefaultsPreservesExplicitMaxResponseBytes(t *testing.T) { + t.Parallel() + + cfg := WebFetchConfig{MaxResponseBytes: 9999} + cfg.ApplyDefaults(WebFetchConfig{MaxResponseBytes: 100}) + if cfg.MaxResponseBytes != 9999 { + t.Fatalf("expected explicit MaxResponseBytes=9999 to be preserved, got %d", cfg.MaxResponseBytes) + } +} + +// ---- Loader 取消上下文测试 ---- + +func TestLoaderLoadWithCanceledContext(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := loader.Load(ctx) + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled error for Load, got %v", err) + } +} + +func TestLoaderSaveWithCanceledContext(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + cfg := testDefaultConfig() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := loader.Save(ctx, cfg) + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled error for Save, got %v", err) + } +} + +// ---- Manager Update 恢复 providers 列表 ---- + +func TestManagerUpdateRestoresProvidersWhenCleared(t *testing.T) { + tempDir := t.TempDir() + manager := NewManager(NewLoader(tempDir, testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + err := manager.Update(context.Background(), func(cfg *Config) error { + cfg.Providers = nil // 清空 provider 列表 + return nil + }) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + + reloaded := manager.Get() + if len(reloaded.Providers) == 0 { + t.Fatal("expected providers to be restored from defaults after clearing") + } +} + +// ---- MCPServerConfig Clone 环境变量独立性 ---- + +func TestMCPServerConfigCloneEnvIndependence(t *testing.T) { + t.Parallel() + + original := MCPServerConfig{ + ID: "test", + Stdio: MCPStdioConfig{Command: "cmd"}, + Env: []MCPEnvVarConfig{ + {Name: "TOKEN", Value: "secret"}, + }, + } + cloned := original.Clone() + cloned.Env[0].Value = "modified" + if original.Env[0].Value == cloned.Env[0].Value { + t.Fatal("expected Env clone to be independent") + } +} + +// ---- cloneProviders 空切片 ---- + +func TestCloneProvidersEmptySlice(t *testing.T) { + t.Parallel() + + result := cloneProviders([]ProviderConfig{}) + if result != nil { + t.Fatalf("expected nil for empty input, got %+v", result) + } +} + +// ---- ProviderByName 空名称 ---- + +func TestProviderByNameEmptyNameOnNonEmptyList(t *testing.T) { + t.Parallel() + + cfg := &Config{Providers: []ProviderConfig{testDefaultProviderConfig()}} + _, err := cfg.ProviderByName("") + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected not found for empty name, got %v", err) + } +} + +// ---- ResolveAPIKey 空环境变量名 ---- + +func TestResolveAPIKeyEmptyEnvName(t *testing.T) { + t.Parallel() + + cfg := ProviderConfig{Name: "test"} + _, err := cfg.ResolveAPIKey() + if err == nil || !strings.Contains(err.Error(), "api_key_env is empty") { + t.Fatalf("expected empty api_key_env error, got %v", err) + } +} + +// ---- Config.Clone nil 接收者返回默认值 ---- + +func TestConfigCloneNilReceiverReturnsDefaults(t *testing.T) { + t.Parallel() + + var cfg *Config + cloned := cfg.Clone() + // Clone(nil) 走 StaticDefaults() 分支,providers 为空,cloneProviders 对空切片返回 nil + if cloned.Workdir == "" { + t.Fatal("expected cloned nil config to have default workdir") + } + if cloned.Shell == "" { + t.Fatal("expected cloned nil config to have default shell") + } + if cloned.MaxLoops == 0 { + t.Fatal("expected cloned nil config to have default max_loops") + } +} diff --git a/internal/config/provider_adapter.go b/internal/config/provider_runtime.go similarity index 53% rename from internal/config/provider_adapter.go rename to internal/config/provider_runtime.go index 89599ee3..2909f1d4 100644 --- a/internal/config/provider_adapter.go +++ b/internal/config/provider_runtime.go @@ -4,20 +4,19 @@ import ( "strings" "neo-code/internal/provider" - providertypes "neo-code/internal/provider/types" ) -// normalizeConfigKey 统一规范化 config 层比较使用的字符串键,避免大小写和空白造成分支漂移。 +// normalizeConfigKey 统一规范 config 层比较使用的字符串键,避免大小写和空白造成分支漂移。 func normalizeConfigKey(value string) string { return provider.NormalizeKey(value) } -// normalizeProviderName 统一规范化 provider 名称,供 config 层查找、去重与比较逻辑复用。 +// normalizeProviderName 统一规范 provider 名称,供 config 层查找、去重与比较逻辑复用。 func normalizeProviderName(name string) string { return provider.NormalizeKey(name) } -// normalizeProviderDriver 统一规范化 driver 名称,供 config 层校验和配置解析分支复用。 +// normalizeProviderDriver 统一规范 driver 名称,供 config 层校验和配置解析分支复用。 func normalizeProviderDriver(driver string) string { return provider.NormalizeProviderDriver(driver) } @@ -50,28 +49,3 @@ func (p ResolvedProviderConfig) ToRuntimeConfig() provider.RuntimeConfig { APIVersion: p.APIVersion, } } - -// NewProviderCatalogInput 基于 provider 配置构造 catalog 查询输入,避免由 ProviderConfig 直接暴露 provider 契约。 -func NewProviderCatalogInput(cfg ProviderConfig) (provider.CatalogInput, error) { - cloned := cloneProviderConfig(cfg) - identity, err := providerIdentityFromConfig(cloned) - if err != nil { - return provider.CatalogInput{}, err - } - - input := provider.CatalogInput{ - Identity: identity, - ConfiguredModels: providertypes.CloneModelDescriptors(cloned.Models), - ResolveDiscoveryConfig: func() (provider.RuntimeConfig, error) { - resolved, err := cloned.Resolve() - if err != nil { - return provider.RuntimeConfig{}, err - } - return resolved.ToRuntimeConfig(), nil - }, - } - if cloned.Source != ProviderSourceCustom { - input.DefaultModels = providertypes.DescriptorsFromIDs([]string{cloned.Model}) - } - return input, nil -} diff --git a/internal/config/provider_runtime_test.go b/internal/config/provider_runtime_test.go new file mode 100644 index 00000000..9e1cfd09 --- /dev/null +++ b/internal/config/provider_runtime_test.go @@ -0,0 +1,40 @@ +package config + +import ( + "testing" + + providerpkg "neo-code/internal/provider" +) + +func TestResolvedProviderConfigToRuntimeConfig(t *testing.T) { + t.Parallel() + + resolved := ResolvedProviderConfig{ + ProviderConfig: ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + Model: "server-default", + APIStyle: "responses", + DeploymentMode: "ignored", + APIVersion: "ignored", + }, + APIKey: "secret-key", + } + + got := resolved.ToRuntimeConfig() + want := providerpkg.RuntimeConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + DefaultModel: "server-default", + APIKey: "secret-key", + APIStyle: "responses", + DeploymentMode: "ignored", + APIVersion: "ignored", + } + + if got != want { + t.Fatalf("ToRuntimeConfig() = %+v, want %+v", got, want) + } +} diff --git a/internal/config/selection.go b/internal/config/selection.go deleted file mode 100644 index d6516dd7..00000000 --- a/internal/config/selection.go +++ /dev/null @@ -1,406 +0,0 @@ -package config - -import ( - "context" - "errors" - "fmt" - "strings" - - "neo-code/internal/provider" - providertypes "neo-code/internal/provider/types" -) - -// Selection 领域错误。 -var ( - ErrProviderNotFound = errors.New("provider not found") - ErrModelNotFound = errors.New("model not found") - ErrNoModelsAvailable = errors.New("provider has no available models") - ErrDriverUnsupported = errors.New("provider driver not supported by current runtime") -) - -// ProviderCatalogItem 表示一个已配置的 provider 及其可用模型列表,用于 UI 展示。 -type ProviderCatalogItem struct { - ID string `json:"id"` - Name string `json:"name"` - Models []providertypes.ModelDescriptor `json:"models,omitempty"` -} - -// ProviderSelection 表示当前选中的 provider 和 model。 -type ProviderSelection struct { - ProviderID string `json:"provider_id"` - ModelID string `json:"model_id"` -} - -// DriverSupporter 用于检查给定 driver 是否被当前运行时支持。 -type DriverSupporter interface { - Supports(driverType string) bool -} - -// ModelCatalog 定义模型目录查询接口,用于获取 provider 的可用模型列表。 -type ModelCatalog interface { - ListProviderModels(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) - ListProviderModelsSnapshot(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) - ListProviderModelsCached(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) -} - -// SelectionService 管理 provider 和模型选择状态,并通过 ConfigManager 持久化变更。 -type SelectionService struct { - manager *Manager - supporters DriverSupporter - catalogs ModelCatalog -} - -// NewSelectionService 创建选择服务实例。 -func NewSelectionService(manager *Manager, supporters DriverSupporter, catalogs ModelCatalog) *SelectionService { - return &SelectionService{ - manager: manager, - supporters: supporters, - catalogs: catalogs, - } -} - -// ListProviders 枚举所有已配置且当前运行时支持的 provider 及其缓存模型列表。 -func (s *SelectionService) ListProviders(ctx context.Context) ([]ProviderCatalogItem, error) { - if err := s.validate(); err != nil { - return nil, err - } - if err := ctx.Err(); err != nil { - return nil, err - } - - cfg := s.manager.Get() - items := make([]ProviderCatalogItem, 0, len(cfg.Providers)) - for _, providerCfg := range cfg.Providers { - if err := ctx.Err(); err != nil { - return nil, err - } - if !s.supporters.Supports(providerCfg.Driver) { - continue - } - - input, err := catalogInputFromProvider(providerCfg) - if err != nil { - return nil, err - } - models, err := s.catalogs.ListProviderModelsCached(ctx, input) - if err != nil { - return nil, err - } - items = append(items, providerCatalogItem(providerCfg, models)) - } - - return items, nil -} - -// SelectProvider 切换当前 provider,并将 current_model 修正为该 provider 下的有效模型。 -func (s *SelectionService) SelectProvider(ctx context.Context, providerName string) (ProviderSelection, error) { - if err := s.validate(); err != nil { - return ProviderSelection{}, err - } - - cfgSnapshot := s.manager.Get() - providerCfg, err := cfgSnapshot.ProviderByName(providerName) - if err != nil { - return ProviderSelection{}, ErrProviderNotFound - } - if err := s.ensureSupportedProvider(providerCfg); err != nil { - return ProviderSelection{}, err - } - - input, err := catalogInputFromProvider(providerCfg) - if err != nil { - return ProviderSelection{}, err - } - var models []providertypes.ModelDescriptor - if providerCfg.Source == ProviderSourceCustom { - models, err = s.catalogs.ListProviderModels(ctx, input) - } else { - models, err = s.catalogs.ListProviderModelsSnapshot(ctx, input) - if len(models) == 0 { - models = providertypes.DescriptorsFromIDs([]string{strings.TrimSpace(providerCfg.Model)}) - } - } - if err != nil { - return ProviderSelection{}, err - } - if len(models) == 0 { - return ProviderSelection{}, ErrNoModelsAvailable - } - - var selection ProviderSelection - err = s.manager.Update(ctx, func(cfg *Config) error { - selected, err := cfg.ProviderByName(providerName) - if err != nil { - return ErrProviderNotFound - } - if err := s.ensureSupportedProvider(selected); err != nil { - return err - } - - cfg.SelectedProvider = selected.Name - nextModel, _ := resolveCurrentModel(cfg.CurrentModel, models, selected.Model) - cfg.CurrentModel = nextModel - selection = selectionFromConfig(*cfg) - return nil - }) - if err != nil { - return ProviderSelection{}, err - } - - return selection, nil -} - -// ListModels 获取当前选中 provider 的模型列表,必要时会同步触发远程发现。 -func (s *SelectionService) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { - if err := s.validate(); err != nil { - return nil, err - } - if err := ctx.Err(); err != nil { - return nil, err - } - - selected, err := s.selectedProviderConfig() - if err != nil { - return nil, err - } - if err := s.ensureSupportedProvider(selected); err != nil { - return nil, err - } - input, err := catalogInputFromProvider(selected) - if err != nil { - return nil, err - } - return s.catalogs.ListProviderModels(ctx, input) -} - -// ListModelsSnapshot 获取当前选中 provider 的快照模型列表,不阻塞等待同步发现。 -func (s *SelectionService) ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) { - if err := s.validate(); err != nil { - return nil, err - } - if err := ctx.Err(); err != nil { - return nil, err - } - - selected, err := s.selectedProviderConfig() - if err != nil { - return nil, err - } - if err := s.ensureSupportedProvider(selected); err != nil { - return nil, err - } - input, err := catalogInputFromProvider(selected) - if err != nil { - return nil, err - } - return s.catalogs.ListProviderModelsSnapshot(ctx, input) -} - -// SetCurrentModel 切换当前模型,目标模型必须出现在当前 provider 的可用模型列表中。 -func (s *SelectionService) SetCurrentModel(ctx context.Context, modelID string) (ProviderSelection, error) { - if err := s.validate(); err != nil { - return ProviderSelection{}, err - } - - modelID = strings.TrimSpace(modelID) - if modelID == "" { - return ProviderSelection{}, ErrModelNotFound - } - - selected, err := s.selectedProviderConfig() - if err != nil { - return ProviderSelection{}, err - } - if err := s.ensureSupportedProvider(selected); err != nil { - return ProviderSelection{}, err - } - - input, err := catalogInputFromProvider(selected) - if err != nil { - return ProviderSelection{}, err - } - models, err := s.catalogs.ListProviderModels(ctx, input) - if err != nil { - return ProviderSelection{}, err - } - if len(models) == 0 { - return ProviderSelection{}, ErrNoModelsAvailable - } - if !containsModelDescriptorID(models, modelID) { - return ProviderSelection{}, ErrModelNotFound - } - - var selection ProviderSelection - err = s.manager.Update(ctx, func(cfg *Config) error { - if _, err := cfg.SelectedProviderConfig(); err != nil { - return err - } - cfg.CurrentModel = modelID - selection = selectionFromConfig(*cfg) - return nil - }) - if err != nil { - return ProviderSelection{}, err - } - - return selection, nil -} - -// EnsureSelection 确保当前 provider 和 model 仍然有效,必要时自动修正。 -// EnsureSelection 统一修正当前选择,使 selected_provider/current_model 始终落在当前可用目录内。 -func (s *SelectionService) EnsureSelection(ctx context.Context) (ProviderSelection, error) { - if err := s.validate(); err != nil { - return ProviderSelection{}, err - } - if err := ctx.Err(); err != nil { - return ProviderSelection{}, err - } - - cfgSnapshot := s.manager.Get() - selected, err := cfgSnapshot.SelectedProviderConfig() - if err != nil { - return ProviderSelection{}, err - } - if err := s.ensureSupportedProvider(selected); err != nil { - return ProviderSelection{}, err - } - - input, err := catalogInputFromProvider(selected) - if err != nil { - return ProviderSelection{}, err - } - models, err := s.catalogs.ListProviderModelsSnapshot(ctx, input) - if err != nil { - return ProviderSelection{}, err - } - if len(models) == 0 { - if selected.Source == ProviderSourceCustom { - if strings.TrimSpace(cfgSnapshot.CurrentModel) == "" { - discovered, discoverErr := s.catalogs.ListProviderModels(ctx, input) - if discoverErr == nil { - models = discovered - } - } - if len(models) == 0 { - return selectionFromConfig(cfgSnapshot), nil - } - } else { - models = providertypes.DescriptorsFromIDs([]string{strings.TrimSpace(selected.Model)}) - } - } - if len(models) == 0 { - return ProviderSelection{}, ErrNoModelsAvailable - } - _, modelChanged := resolveCurrentModel(cfgSnapshot.CurrentModel, models, selected.Model) - if !modelChanged { - return selectionFromConfig(cfgSnapshot), nil - } - - var selection ProviderSelection - err = s.manager.Update(ctx, func(cfg *Config) error { - currentSelected, err := cfg.SelectedProviderConfig() - if err != nil { - return err - } - if normalizeProviderName(currentSelected.Name) != normalizeProviderName(selected.Name) { - return ErrProviderNotFound - } - cfg.CurrentModel, _ = resolveCurrentModel(cfg.CurrentModel, models, currentSelected.Model) - selection = selectionFromConfig(*cfg) - return nil - }) - if err != nil { - return ProviderSelection{}, err - } - - return selection, nil -} - -func (s *SelectionService) validate() error { - if s == nil { - return errors.New("selection: service is nil") - } - if s.manager == nil { - return errors.New("selection: config manager is nil") - } - if s.supporters == nil { - return errors.New("selection: driver supporter is nil") - } - if s.catalogs == nil { - return errors.New("selection: catalog service is nil") - } - return nil -} - -func (s *SelectionService) selectedProviderConfig() (ProviderConfig, error) { - cfg := s.manager.Get() - return cfg.SelectedProviderConfig() -} - -// ensureSupportedProvider 统一校验当前运行时是否支持指定 provider,确保各选择入口返回一致的类型化错误。 -func (s *SelectionService) ensureSupportedProvider(cfg ProviderConfig) error { - if s.supporters.Supports(cfg.Driver) { - return nil - } - return fmt.Errorf( - "selection: provider %q driver %q: %w", - cfg.Name, - cfg.Driver, - ErrDriverUnsupported, - ) -} - -func selectionFromConfig(cfg Config) ProviderSelection { - return ProviderSelection{ - ProviderID: cfg.SelectedProvider, - ModelID: cfg.CurrentModel, - } -} - -func resolveCurrentModel(currentModel string, models []providertypes.ModelDescriptor, fallback string) (string, bool) { - currentModel = strings.TrimSpace(currentModel) - if containsModelDescriptorID(models, currentModel) { - return currentModel, false - } - - fallback = strings.TrimSpace(fallback) - if fallback != "" && containsModelDescriptorID(models, fallback) { - return fallback, currentModel != fallback - } - - for _, model := range models { - id := strings.TrimSpace(model.ID) - if id != "" { - return id, currentModel != id - } - } - - return currentModel, false -} - -func providerCatalogItem(cfg ProviderConfig, models []providertypes.ModelDescriptor) ProviderCatalogItem { - return ProviderCatalogItem{ - ID: strings.TrimSpace(cfg.Name), - Name: strings.TrimSpace(cfg.Name), - Models: providertypes.MergeModelDescriptors(models), - } -} - -// catalogInputFromProvider 将配置层 provider 定义转换为 catalog 查询输入,统一收敛适配错误处理。 -func catalogInputFromProvider(cfg ProviderConfig) (provider.CatalogInput, error) { - return NewProviderCatalogInput(cfg) -} - -func containsModelDescriptorID(models []providertypes.ModelDescriptor, modelID string) bool { - target := normalizeConfigKey(modelID) - if target == "" { - return false - } - - for _, model := range models { - if normalizeConfigKey(model.ID) == target { - return true - } - } - return false -} diff --git a/internal/config/state/model.go b/internal/config/state/model.go new file mode 100644 index 00000000..1992eaf3 --- /dev/null +++ b/internal/config/state/model.go @@ -0,0 +1,152 @@ +package state + +import ( + "context" + "errors" + "fmt" + "strings" + + "neo-code/internal/config" + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" +) + +// Selection 记录当前激活的 provider 和 model。 +type Selection struct { + ProviderID string `json:"provider_id"` + ModelID string `json:"model_id"` +} + +// ProviderOption 表示当前运行时可供选择的 provider 及其模型候选。 +type ProviderOption struct { + ID string `json:"id"` + Name string `json:"name"` + Models []providertypes.ModelDescriptor `json:"models,omitempty"` +} + +// 选择状态领域错误。 +var ( + ErrProviderNotFound = errors.New("provider not found") + ErrModelNotFound = errors.New("model not found") + ErrNoModelsAvailable = errors.New("provider has no available models") + ErrDriverUnsupported = errors.New("provider driver not supported by current runtime") +) + +// DriverSupporter 用于检查给定 driver 是否受当前运行时支持。 +type DriverSupporter interface { + Supports(driverType string) bool +} + +// ModelCatalog 定义模型目录查询接口,用于获取 provider 的可用模型列表。 +type ModelCatalog interface { + ListProviderModels(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) + ListProviderModelsSnapshot(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) + ListProviderModelsCached(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) +} + +// selectionFromConfig 将配置快照映射为当前选择结果。 +func selectionFromConfig(cfg config.Config) Selection { + return Selection{ + ProviderID: cfg.SelectedProvider, + ModelID: cfg.CurrentModel, + } +} + +// resolveCurrentModel 依据候选模型列表修正当前模型,并返回是否发生变更。 +func resolveCurrentModel(currentModel string, models []providertypes.ModelDescriptor, fallback string) (string, bool) { + currentModel = strings.TrimSpace(currentModel) + if containsModelDescriptorID(models, currentModel) { + return currentModel, false + } + + fallback = strings.TrimSpace(fallback) + if fallback != "" && containsModelDescriptorID(models, fallback) { + return fallback, currentModel != fallback + } + + for _, model := range models { + id := strings.TrimSpace(model.ID) + if id != "" { + return id, currentModel != id + } + } + + return currentModel, false +} + +// providerOption 将 provider 配置与模型列表整理为选择服务返回的候选项。 +func providerOption(cfg config.ProviderConfig, models []providertypes.ModelDescriptor) ProviderOption { + return ProviderOption{ + ID: strings.TrimSpace(cfg.Name), + Name: strings.TrimSpace(cfg.Name), + Models: providertypes.MergeModelDescriptors(models), + } +} + +// catalogInputFromProvider 将配置层 provider 定义转换为 catalog 查询输入。 +func catalogInputFromProvider(cfg config.ProviderConfig) (provider.CatalogInput, error) { + cloned := cfg + cloned.Models = providertypes.CloneModelDescriptors(cfg.Models) + + identity, err := cfg.Identity() + if err != nil { + return provider.CatalogInput{}, err + } + + input := provider.CatalogInput{ + Identity: identity, + ConfiguredModels: providertypes.CloneModelDescriptors(cloned.Models), + ResolveDiscoveryConfig: func() (provider.RuntimeConfig, error) { + resolved, err := cloned.Resolve() + if err != nil { + return provider.RuntimeConfig{}, err + } + return resolved.ToRuntimeConfig(), nil + }, + } + if cloned.Source != config.ProviderSourceCustom { + input.DefaultModels = providertypes.DescriptorsFromIDs([]string{cloned.Model}) + } + return input, nil +} + +// containsModelDescriptorID 判断模型列表中是否包含目标 ID。 +func containsModelDescriptorID(models []providertypes.ModelDescriptor, modelID string) bool { + target := provider.NormalizeKey(modelID) + if target == "" { + return false + } + + for _, model := range models { + if provider.NormalizeKey(model.ID) == target { + return true + } + } + return false +} + +// selectedProviderConfig 解析当前配置中记录的选中 provider。 +func selectedProviderConfig(cfg config.Config) (config.ProviderConfig, error) { + name := strings.TrimSpace(cfg.SelectedProvider) + if name == "" { + return config.ProviderConfig{}, ErrProviderNotFound + } + providerCfg, err := cfg.ProviderByName(name) + if err != nil { + return config.ProviderConfig{}, ErrProviderNotFound + } + return providerCfg, nil +} + +// ensureSupportedProvider 统一校验当前运行时是否支持指定 provider。 +func ensureSupportedProvider(supporters DriverSupporter, cfg config.ProviderConfig) error { + if supporters.Supports(cfg.Driver) { + return nil + } + return fmt.Errorf( + "selection: provider %q driver %q: %w", + cfg.Name, + cfg.Driver, + ErrDriverUnsupported, + ) +} diff --git a/internal/config/state/model_additional_test.go b/internal/config/state/model_additional_test.go new file mode 100644 index 00000000..09aacba6 --- /dev/null +++ b/internal/config/state/model_additional_test.go @@ -0,0 +1,520 @@ +package state + +import ( + "context" + "errors" + "strings" + "testing" + + configpkg "neo-code/internal/config" + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" +) + +// ---- state/model.go 辅助函数测试 ---- + +func TestSelectionFromConfig(t *testing.T) { + t.Parallel() + + cfg := configpkg.Config{ + SelectedProvider: "test-provider", + CurrentModel: "test-model", + } + sel := selectionFromConfig(cfg) + if sel.ProviderID != "test-provider" { + t.Fatalf("expected ProviderID=test-provider, got %q", sel.ProviderID) + } + if sel.ModelID != "test-model" { + t.Fatalf("expected ModelID=test-model, got %q", sel.ModelID) + } +} + +func TestSelectionFromConfigEmptyFields(t *testing.T) { + t.Parallel() + + cfg := configpkg.Config{} + sel := selectionFromConfig(cfg) + if sel.ProviderID != "" || sel.ModelID != "" { + t.Fatalf("expected empty selection for empty config, got %+v", sel) + } +} + +func TestResolveCurrentModelAdditionalEdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + currentModel string + models []providertypes.ModelDescriptor + fallback string + expected string + changed bool + }{ + { + name: "empty current returns fallback", + currentModel: "", + models: []providertypes.ModelDescriptor{{ID: "gpt-4o"}, {ID: "gpt-5.4"}}, + fallback: "gpt-5.4", + expected: "gpt-5.4", + changed: true, + }, + { + name: "whitespace current trimmed", + currentModel: " gpt-4o ", + models: []providertypes.ModelDescriptor{{ID: "gpt-4o"}}, + fallback: "fallback", + expected: "gpt-4o", + changed: false, + }, + { + name: "empty models list returns current unchanged", + currentModel: "gpt-4o", + models: []providertypes.ModelDescriptor{}, + fallback: "fallback", + expected: "gpt-4o", + changed: false, + }, + { + name: "nil models list returns current unchanged", + currentModel: "gpt-4o", + models: nil, + fallback: "fallback", + expected: "gpt-4o", + changed: false, + }, + { + name: "current not found, fallback also not found, uses first available", + currentModel: "unknown", + models: []providertypes.ModelDescriptor{{ID: "first"}, {ID: "second"}}, + fallback: "also-unknown", + expected: "first", + changed: true, + }, + { + name: "current not found, empty fallback, uses first available", + currentModel: "unknown", + models: []providertypes.ModelDescriptor{{ID: "first"}, {ID: "second"}}, + fallback: "", + expected: "first", + changed: true, + }, + { + name: "model with whitespace ID matches after trim", + currentModel: "gpt-4o", + models: []providertypes.ModelDescriptor{{ID: " gpt-4o "}}, + fallback: "fallback", + expected: "gpt-4o", + changed: false, + }, + { + name: "empty current and empty fallback, use first non-empty model", + currentModel: "", + models: []providertypes.ModelDescriptor{{ID: ""}, {ID: "real-model"}}, + fallback: "", + expected: "real-model", + changed: true, + }, + { + name: "all models have empty IDs, return current unchanged", + currentModel: "some-model", + models: []providertypes.ModelDescriptor{{ID: ""}, {ID: " "}}, + fallback: "", + expected: "some-model", + changed: false, + }, + { + name: "whitespace fallback is treated as empty", + currentModel: "missing", + models: []providertypes.ModelDescriptor{{ID: "found"}}, + fallback: " ", + expected: "found", + changed: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, changed := resolveCurrentModel(tt.currentModel, tt.models, tt.fallback) + if got != tt.expected || changed != tt.changed { + t.Fatalf("resolveCurrentModel() = (%q, %v), want (%q, %v)", + got, changed, tt.expected, tt.changed) + } + }) + } +} + +func TestContainsModelDescriptorID(t *testing.T) { + t.Parallel() + + models := []providertypes.ModelDescriptor{ + {ID: "gpt-4o"}, + {ID: "gemini-pro"}, + } + if !containsModelDescriptorID(models, "GPT-4O") { + t.Fatal("expected case-insensitive match") + } + if containsModelDescriptorID(models, "claude") { + t.Fatal("expected no match") + } + if containsModelDescriptorID(nil, "gpt-4o") { + t.Fatal("expected nil models to return false") + } + if containsModelDescriptorID(models, "") { + t.Fatal("expected empty ID to return false") + } + if containsModelDescriptorID(models, " ") { + t.Fatal("expected whitespace ID to return false") + } +} + +func TestProviderOptionNormalizesFields(t *testing.T) { + t.Parallel() + + option := providerOption(configpkg.ProviderConfig{Name: " OpenAI "}, []providertypes.ModelDescriptor{ + {ID: "gpt-4o", Name: "GPT-4o"}, + }) + if option.ID != "OpenAI" { + t.Fatalf("expected trimmed ID, got %q", option.ID) + } + if option.Name != "OpenAI" { + t.Fatalf("expected trimmed Name, got %q", option.Name) + } + if len(option.Models) == 0 { + t.Fatal("expected models to be populated") + } +} + +func TestSelectedProviderConfigEmptySelection(t *testing.T) { + t.Parallel() + + cfg := configpkg.Config{} + _, err := selectedProviderConfig(cfg) + if !errors.Is(err, ErrProviderNotFound) { + t.Fatalf("expected ErrProviderNotFound for empty selection, got %v", err) + } +} + +func TestSelectedProviderConfigNotFound(t *testing.T) { + t.Parallel() + + cfg := configpkg.Config{ + SelectedProvider: "nonexistent", + Providers: []configpkg.ProviderConfig{ + {Name: "openai"}, + }, + } + _, err := selectedProviderConfig(cfg) + if !errors.Is(err, ErrProviderNotFound) { + t.Fatalf("expected ErrProviderNotFound, got %v", err) + } +} + +func TestEnsureSupportedProvider(t *testing.T) { + t.Parallel() + + supporters := &selectiveDriverSupporter{supported: map[string]bool{"openaicompat": true}} + + err := ensureSupportedProvider(supporters, configpkg.ProviderConfig{ + Name: "test", + Driver: "anthropic", + }) + if !errors.Is(err, ErrDriverUnsupported) { + t.Fatalf("expected ErrDriverUnsupported for anthropic driver, got %v", err) + } + if !strings.Contains(err.Error(), `"test"`) { + t.Fatalf("expected error to contain provider name, got %v", err) + } + if !strings.Contains(err.Error(), `"anthropic"`) { + t.Fatalf("expected error to contain driver name, got %v", err) + } + + // 支持的 driver 不应返回错误 + err = ensureSupportedProvider(supporters, configpkg.ProviderConfig{ + Name: "test", + Driver: "openaicompat", + }) + if err != nil { + t.Fatalf("expected no error for supported driver, got %v", err) + } +} + +// ---- state/service.go 错误路径测试 ---- + +type additionalCatalogStub struct{} + +func (additionalCatalogStub) ListProviderModels(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return nil, nil +} + +func (additionalCatalogStub) ListProviderModelsSnapshot(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return nil, nil +} + +func (additionalCatalogStub) ListProviderModelsCached(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return nil, nil +} + +type denyAllDriverSupporter struct{} + +func (*denyAllDriverSupporter) Supports(_ string) bool { return false } + +func TestSelectionServiceSelectProviderNoModelsAvailable(t *testing.T) { + t.Parallel() + + // 使用自定义 provider,因为 builtin provider 在 snapshot 返回空时会回退到默认模型 + defaults := testDefaultConfig() + defaults.Providers = append(defaults.Providers, configpkg.ProviderConfig{ + Name: "empty-model-provider", + Driver: "openaicompat", + BaseURL: "https://example.com/v1", + APIKeyEnv: "EMPTY_PROVIDER_KEY", + Source: ProviderSourceCustom, + }) + + manager := newSelectionTestManager(t, defaults) + service := NewService(manager, newDriverSupporterStub(), additionalCatalogStub{}) + + _, err := service.SelectProvider(context.Background(), "empty-model-provider") + if !errors.Is(err, ErrNoModelsAvailable) { + t.Fatalf("expected ErrNoModelsAvailable, got %v", err) + } +} + +func TestSelectionServiceSetCurrentModelNoModelsAvailable(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, newDriverSupporterStub(), additionalCatalogStub{}) + + _, err := service.SetCurrentModel(context.Background(), "some-model") + if !errors.Is(err, ErrNoModelsAvailable) { + t.Fatalf("expected ErrNoModelsAvailable, got %v", err) + } +} + +func TestSelectionServiceListModelsUnsupportedDriver(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, testDefaultConfig()) + supporters := &denyAllDriverSupporter{} + service := NewService(manager, supporters, newCatalogStub()) + + _, err := service.ListModels(context.Background()) + if !errors.Is(err, ErrDriverUnsupported) { + t.Fatalf("expected ErrDriverUnsupported, got %v", err) + } +} + +func TestSelectionServiceEnsureSelectionBootstrapInitialWhenNoSelection(t *testing.T) { + t.Parallel() + + defaults := testDefaultConfig() + defaults.SelectedProvider = "" + defaults.CurrentModel = "" + + manager := newSelectionTestManager(t, defaults) + service := NewService(manager, newDriverSupporterStub(), newCatalogStub()) + + selection, err := service.EnsureSelection(context.Background()) + if err != nil { + t.Fatalf("EnsureSelection() bootstrap error = %v", err) + } + if selection.ProviderID == "" { + t.Fatal("expected non-empty ProviderID after bootstrap") + } + + reloaded, _ := manager.Reload(context.Background()) + if reloaded.SelectedProvider == "" { + t.Fatal("expected persisted selection after bootstrap") + } +} + +func TestSelectionServiceEnsureSelectionNoSupportedProviders(t *testing.T) { + t.Parallel() + + defaults := testDefaultConfig() + defaults.SelectedProvider = "" + + manager := newSelectionTestManager(t, defaults) + supporters := &denyAllDriverSupporter{} + service := NewService(manager, supporters, newCatalogStub()) + + _, err := service.EnsureSelection(context.Background()) + if !errors.Is(err, ErrProviderNotFound) { + t.Fatalf("expected ErrProviderNotFound when no supported providers, got %v", err) + } +} + +func TestSelectionServiceListProviderOptionsSkipsUnsupportedDrivers(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, testDefaultConfig()) + supporters := &denyAllDriverSupporter{} + service := NewService(manager, supporters, newCatalogStub()) + + options, err := service.ListProviderOptions(context.Background()) + if err != nil { + t.Fatalf("ListProviderOptions() error = %v", err) + } + if len(options) != 0 { + t.Fatalf("expected zero options when all drivers unsupported, got %d", len(options)) + } +} + +// ---- catalogInputFromProvider 边界测试 ---- + +func TestCatalogInputFromProviderCustomWithoutModelField(t *testing.T) { + t.Setenv("CUSTOM_NO_MODEL_KEY", "secret-key") + + input, err := catalogInputFromProvider(configpkg.ProviderConfig{ + Name: "custom-gateway", + Driver: "openaicompat", + BaseURL: "https://example.com/v1", + APIKeyEnv: "CUSTOM_NO_MODEL_KEY", + Source: configpkg.ProviderSourceCustom, + // Model 故意为空,自定义 provider 可以不设 model + }) + if err != nil { + t.Fatalf("catalogInputFromProvider() error = %v", err) + } + if input.DefaultModels != nil { + t.Fatalf("expected custom provider without model to have nil DefaultModels, got %+v", input.DefaultModels) + } +} + +// ---- EnsureSelection 自定义 provider 无快照且有模型时的回退 ---- + +func TestEnsureSelectionCustomProviderWithSnapshotModels(t *testing.T) { + t.Parallel() + + defaults := testDefaultConfig() + defaults.Providers = append(defaults.Providers, configpkg.ProviderConfig{ + Name: "custom-gateway", + Driver: "openaicompat", + BaseURL: "https://example.com/v1", + APIKeyEnv: "CUSTOM_KEY", + Source: ProviderSourceCustom, + }) + defaults.SelectedProvider = "custom-gateway" + defaults.CurrentModel = "unknown-model" + + manager := newSelectionTestManager(t, defaults) + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStubForAdditional{ + snapshotModels: []providertypes.ModelDescriptor{ + {ID: "server-model-1"}, + }, + }) + + selection, err := service.EnsureSelection(context.Background()) + if err != nil { + t.Fatalf("EnsureSelection() error = %v", err) + } + if selection.ModelID != "server-model-1" { + t.Fatalf("expected fallback to snapshot model, got %q", selection.ModelID) + } +} + +// ---- SelectProvider 更新失败时状态不变 ---- + +func TestSelectProviderUpdateFailsPreservesState(t *testing.T) { + t.Parallel() + + defaults := testDefaultConfig() + defaults.Providers = append(defaults.Providers, configpkg.ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + Source: ProviderSourceCustom, + }) + // 先加载初始配置 + manager := newSelectionTestManager(t, defaults) + service := NewService(manager, &failingDriverSupporter{}, newCatalogStub()) + + _, err := service.SelectProvider(context.Background(), "company-gateway") + if !errors.Is(err, ErrDriverUnsupported) { + t.Fatalf("expected ErrDriverUnsupported, got %v", err) + } + + cfg := manager.Get() + if cfg.SelectedProvider == "company-gateway" { + t.Fatal("expected selected provider to stay unchanged after failed select") + } +} + +// ---- SetCurrentModel 更新内部校验失败 ---- + +func TestSetCurrentModelInternalValidationFails(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, testDefaultConfig()) + + // 先切换到一个自定义 provider + err := manager.Update(context.Background(), func(cfg *configpkg.Config) error { + cfg.SelectedProvider = "nonexistent-provider" + return nil + }) + if err != nil { + t.Fatalf("setup update failed: %v", err) + } + + service := NewService(manager, newDriverSupporterStub(), newCatalogStub()) + // SetCurrentModel 会先找到当前选中 provider,但 Update 内部再次查找会失败 + _, err = service.SetCurrentModel(context.Background(), OpenAIDefaultModel) + if err == nil { + t.Fatal("expected error when current selection is invalid") + } +} + +// ---- 测试辅助 ---- + +type failingDriverSupporter struct{} + +func (*failingDriverSupporter) Supports(_ string) bool { return false } + +type catalogMethodsStubForAdditional struct { + listModels []providertypes.ModelDescriptor + snapshotModels []providertypes.ModelDescriptor + cachedModels []providertypes.ModelDescriptor + listErr error + snapshotErr error + cachedErr error + tracker *catalogMethodCallsForAdditional +} + +func (s catalogMethodsStubForAdditional) ListProviderModels(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + if s.tracker != nil { + s.tracker.listCalls++ + } + if s.listErr != nil { + return nil, s.listErr + } + return s.listModels, nil +} + +func (s catalogMethodsStubForAdditional) ListProviderModelsSnapshot(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + if s.tracker != nil { + s.tracker.snapshotCalls++ + } + if s.snapshotErr != nil { + return nil, s.snapshotErr + } + return s.snapshotModels, nil +} + +func (s catalogMethodsStubForAdditional) ListProviderModelsCached(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + if s.tracker != nil { + s.tracker.cachedCalls++ + } + if s.cachedErr != nil { + return nil, s.cachedErr + } + return s.cachedModels, nil +} + +type catalogMethodCallsForAdditional struct { + listCalls int + snapshotCalls int + cachedCalls int +} diff --git a/internal/config/provider_adapter_test.go b/internal/config/state/model_test.go similarity index 64% rename from internal/config/provider_adapter_test.go rename to internal/config/state/model_test.go index 8636e950..22c328c8 100644 --- a/internal/config/provider_adapter_test.go +++ b/internal/config/state/model_test.go @@ -1,50 +1,18 @@ -package config +package state import ( "strings" "testing" + configpkg "neo-code/internal/config" providerpkg "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" ) -func TestResolvedProviderConfigToRuntimeConfig(t *testing.T) { - t.Parallel() - - resolved := ResolvedProviderConfig{ - ProviderConfig: ProviderConfig{ - Name: "company-gateway", - Driver: "openaicompat", - BaseURL: "https://llm.example.com/v1", - Model: "server-default", - APIStyle: "responses", - DeploymentMode: "ignored", - APIVersion: "ignored", - }, - APIKey: "secret-key", - } - - got := resolved.ToRuntimeConfig() - want := providerpkg.RuntimeConfig{ - Name: "company-gateway", - Driver: "openaicompat", - BaseURL: "https://llm.example.com/v1", - DefaultModel: "server-default", - APIKey: "secret-key", - APIStyle: "responses", - DeploymentMode: "ignored", - APIVersion: "ignored", - } - - if got != want { - t.Fatalf("ToRuntimeConfig() = %+v, want %+v", got, want) - } -} - -func TestNewProviderCatalogInputBuiltinIncludesDefaultsAndLazyDiscovery(t *testing.T) { +func TestCatalogInputFromProviderBuiltinIncludesDefaultsAndLazyDiscovery(t *testing.T) { t.Setenv("CATALOG_PROVIDER_API_KEY", "secret-key") - cfg := ProviderConfig{ + cfg := configpkg.ProviderConfig{ Name: "company-gateway", Driver: "openaicompat", BaseURL: "https://API.EXAMPLE.COM/v1/", @@ -54,12 +22,12 @@ func TestNewProviderCatalogInputBuiltinIncludesDefaultsAndLazyDiscovery(t *testi Models: []providertypes.ModelDescriptor{ {ID: " model-a ", Name: " Model A "}, }, - Source: ProviderSourceBuiltin, + Source: configpkg.ProviderSourceBuiltin, } - input, err := NewProviderCatalogInput(cfg) + input, err := catalogInputFromProvider(cfg) if err != nil { - t.Fatalf("NewProviderCatalogInput() error = %v", err) + t.Fatalf("catalogInputFromProvider() error = %v", err) } if input.Identity.Driver != "openaicompat" { @@ -95,19 +63,19 @@ func TestNewProviderCatalogInputBuiltinIncludesDefaultsAndLazyDiscovery(t *testi } } -func TestNewProviderCatalogInputDefaultsOpenAICompatibleIdentityAPIStyle(t *testing.T) { +func TestCatalogInputFromProviderDefaultsOpenAICompatibleIdentityAPIStyle(t *testing.T) { t.Setenv("CATALOG_PROVIDER_API_KEY", "secret-key") - input, err := NewProviderCatalogInput(ProviderConfig{ + input, err := catalogInputFromProvider(configpkg.ProviderConfig{ Name: "company-gateway", Driver: "openaicompat", BaseURL: "https://API.EXAMPLE.COM/v1/", Model: "server-default", APIKeyEnv: "CATALOG_PROVIDER_API_KEY", - Source: ProviderSourceBuiltin, + Source: configpkg.ProviderSourceBuiltin, }) if err != nil { - t.Fatalf("NewProviderCatalogInput() error = %v", err) + t.Fatalf("catalogInputFromProvider() error = %v", err) } if input.Identity.APIStyle != providerpkg.OpenAICompatibleAPIStyleChatCompletions { @@ -127,33 +95,33 @@ func TestNewProviderCatalogInputDefaultsOpenAICompatibleIdentityAPIStyle(t *test } } -func TestNewProviderCatalogInputCustomOmitsDefaultModels(t *testing.T) { +func TestCatalogInputFromProviderCustomOmitsDefaultModels(t *testing.T) { t.Setenv("CATALOG_PROVIDER_API_KEY", "secret-key") - input, err := NewProviderCatalogInput(ProviderConfig{ + input, err := catalogInputFromProvider(configpkg.ProviderConfig{ Name: "company-gateway", Driver: "openaicompat", BaseURL: "https://llm.example.com/v1", APIKeyEnv: "CATALOG_PROVIDER_API_KEY", - Source: ProviderSourceCustom, + Source: configpkg.ProviderSourceCustom, }) if err != nil { - t.Fatalf("NewProviderCatalogInput() error = %v", err) + t.Fatalf("catalogInputFromProvider() error = %v", err) } if input.DefaultModels != nil { t.Fatalf("expected custom provider to omit default models, got %+v", input.DefaultModels) } } -func TestNewProviderCatalogInputPropagatesIdentityErrors(t *testing.T) { +func TestCatalogInputFromProviderPropagatesIdentityErrors(t *testing.T) { t.Parallel() - _, err := NewProviderCatalogInput(ProviderConfig{ + _, err := catalogInputFromProvider(configpkg.ProviderConfig{ Name: "broken-provider", Driver: "openaicompat", BaseURL: " ", APIKeyEnv: "CATALOG_PROVIDER_API_KEY", - Source: ProviderSourceBuiltin, + Source: configpkg.ProviderSourceBuiltin, }) if err == nil || !strings.Contains(err.Error(), "base_url is empty") { t.Fatalf("expected base_url validation error, got %v", err) diff --git a/internal/config/state/service.go b/internal/config/state/service.go new file mode 100644 index 00000000..5b586b91 --- /dev/null +++ b/internal/config/state/service.go @@ -0,0 +1,319 @@ +package state + +import ( + "context" + "errors" + "strings" + + "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" +) + +// Service 管理当前 provider/model 选择状态,并通过 config.Manager 持久化变更。 +type Service struct { + manager *config.Manager + supporters DriverSupporter + catalogs ModelCatalog +} + +// NewService 创建选择状态服务实例。 +func NewService(manager *config.Manager, supporters DriverSupporter, catalogs ModelCatalog) *Service { + return &Service{ + manager: manager, + supporters: supporters, + catalogs: catalogs, + } +} + +// ListProviderOptions 枚举所有已配置且当前运行时支持的 provider 及其缓存模型列表。 +func (s *Service) ListProviderOptions(ctx context.Context) ([]ProviderOption, error) { + if err := s.validate(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + cfg := s.manager.Get() + options := make([]ProviderOption, 0, len(cfg.Providers)) + for _, providerCfg := range cfg.Providers { + if err := ctx.Err(); err != nil { + return nil, err + } + if !s.supporters.Supports(providerCfg.Driver) { + continue + } + + input, err := catalogInputFromProvider(providerCfg) + if err != nil { + return nil, err + } + models, err := s.catalogs.ListProviderModelsCached(ctx, input) + if err != nil { + return nil, err + } + options = append(options, providerOption(providerCfg, models)) + } + + return options, nil +} + +// SelectProvider 切换当前 provider,并将 current_model 修正为该 provider 下的有效模型。 +func (s *Service) SelectProvider(ctx context.Context, providerName string) (Selection, error) { + if err := s.validate(); err != nil { + return Selection{}, err + } + + cfgSnapshot := s.manager.Get() + providerCfg, err := cfgSnapshot.ProviderByName(providerName) + if err != nil { + return Selection{}, ErrProviderNotFound + } + if err := ensureSupportedProvider(s.supporters, providerCfg); err != nil { + return Selection{}, err + } + + input, err := catalogInputFromProvider(providerCfg) + if err != nil { + return Selection{}, err + } + var models []providertypes.ModelDescriptor + if providerCfg.Source == config.ProviderSourceCustom { + models, err = s.catalogs.ListProviderModels(ctx, input) + } else { + models, err = s.catalogs.ListProviderModelsSnapshot(ctx, input) + if len(models) == 0 { + models = providertypes.DescriptorsFromIDs([]string{strings.TrimSpace(providerCfg.Model)}) + } + } + if err != nil { + return Selection{}, err + } + if len(models) == 0 { + return Selection{}, ErrNoModelsAvailable + } + + var selection Selection + err = s.manager.Update(ctx, func(cfg *config.Config) error { + selected, err := cfg.ProviderByName(providerName) + if err != nil { + return ErrProviderNotFound + } + if err := ensureSupportedProvider(s.supporters, selected); err != nil { + return err + } + + cfg.SelectedProvider = selected.Name + nextModel, _ := resolveCurrentModel(cfg.CurrentModel, models, selected.Model) + cfg.CurrentModel = nextModel + selection = selectionFromConfig(*cfg) + return nil + }) + if err != nil { + return Selection{}, err + } + + return selection, nil +} + +// ListModels 获取当前选中 provider 的模型列表,必要时会同步触发远程发现。 +func (s *Service) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { + if err := s.validate(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + selected, err := selectedProviderConfig(s.manager.Get()) + if err != nil { + return nil, err + } + if err := ensureSupportedProvider(s.supporters, selected); err != nil { + return nil, err + } + input, err := catalogInputFromProvider(selected) + if err != nil { + return nil, err + } + return s.catalogs.ListProviderModels(ctx, input) +} + +// ListModelsSnapshot 获取当前选中 provider 的快照模型列表,不阻塞等待同步发现。 +func (s *Service) ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) { + if err := s.validate(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + selected, err := selectedProviderConfig(s.manager.Get()) + if err != nil { + return nil, err + } + if err := ensureSupportedProvider(s.supporters, selected); err != nil { + return nil, err + } + input, err := catalogInputFromProvider(selected) + if err != nil { + return nil, err + } + return s.catalogs.ListProviderModelsSnapshot(ctx, input) +} + +// SetCurrentModel 切换当前模型,目标模型必须出现在当前 provider 的可用模型列表中。 +func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (Selection, error) { + if err := s.validate(); err != nil { + return Selection{}, err + } + + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return Selection{}, ErrModelNotFound + } + + selected, err := selectedProviderConfig(s.manager.Get()) + if err != nil { + return Selection{}, err + } + if err := ensureSupportedProvider(s.supporters, selected); err != nil { + return Selection{}, err + } + + input, err := catalogInputFromProvider(selected) + if err != nil { + return Selection{}, err + } + models, err := s.catalogs.ListProviderModels(ctx, input) + if err != nil { + return Selection{}, err + } + if len(models) == 0 { + return Selection{}, ErrNoModelsAvailable + } + if !containsModelDescriptorID(models, modelID) { + return Selection{}, ErrModelNotFound + } + + var selection Selection + err = s.manager.Update(ctx, func(cfg *config.Config) error { + if _, err := selectedProviderConfig(*cfg); err != nil { + return err + } + cfg.CurrentModel = modelID + selection = selectionFromConfig(*cfg) + return nil + }) + if err != nil { + return Selection{}, err + } + + return selection, nil +} + +// EnsureSelection 确保当前 provider 和 model 仍然有效,必要时自动修正。 +func (s *Service) EnsureSelection(ctx context.Context) (Selection, error) { + if err := s.validate(); err != nil { + return Selection{}, err + } + if err := ctx.Err(); err != nil { + return Selection{}, err + } + + cfgSnapshot := s.manager.Get() + selected, err := selectedProviderConfig(cfgSnapshot) + if err != nil { + selected, err = s.bootstrapInitialSelection(ctx, cfgSnapshot) + if err != nil { + return Selection{}, err + } + } + if err := ensureSupportedProvider(s.supporters, selected); err != nil { + return Selection{}, err + } + + input, err := catalogInputFromProvider(selected) + if err != nil { + return Selection{}, err + } + models, err := s.catalogs.ListProviderModelsSnapshot(ctx, input) + if err != nil { + return Selection{}, err + } + if len(models) == 0 { + if selected.Source == config.ProviderSourceCustom { + if strings.TrimSpace(cfgSnapshot.CurrentModel) == "" { + discovered, discoverErr := s.catalogs.ListProviderModels(ctx, input) + if discoverErr == nil { + models = discovered + } + } + if len(models) == 0 { + return selectionFromConfig(cfgSnapshot), nil + } + } else { + models = providertypes.DescriptorsFromIDs([]string{strings.TrimSpace(selected.Model)}) + } + } + if len(models) == 0 { + return Selection{}, ErrNoModelsAvailable + } + _, modelChanged := resolveCurrentModel(cfgSnapshot.CurrentModel, models, selected.Model) + if !modelChanged && strings.TrimSpace(cfgSnapshot.SelectedProvider) != "" { + return selectionFromConfig(cfgSnapshot), nil + } + + var selection Selection + err = s.manager.Update(ctx, func(cfg *config.Config) error { + currentSelected, err := selectedProviderConfig(*cfg) + if err != nil { + currentSelected = selected + cfg.SelectedProvider = selected.Name + } + cfg.CurrentModel, _ = resolveCurrentModel(cfg.CurrentModel, models, currentSelected.Model) + selection = selectionFromConfig(*cfg) + return nil + }) + if err != nil { + return Selection{}, err + } + + return selection, nil +} + +// bootstrapInitialSelection 为缺失当前选择状态的配置建立一个干净的初始选择。 +func (s *Service) bootstrapInitialSelection(ctx context.Context, cfg config.Config) (config.ProviderConfig, error) { + for _, providerCfg := range cfg.Providers { + if err := ensureSupportedProvider(s.supporters, providerCfg); err != nil { + continue + } + err := s.manager.Update(ctx, func(current *config.Config) error { + current.SelectedProvider = providerCfg.Name + current.CurrentModel = strings.TrimSpace(providerCfg.Model) + return nil + }) + if err != nil { + return config.ProviderConfig{}, err + } + return providerCfg, nil + } + return config.ProviderConfig{}, ErrProviderNotFound +} + +// validate 校验服务依赖是否完整。 +func (s *Service) validate() error { + if s == nil { + return errors.New("selection: service is nil") + } + if s.manager == nil { + return errors.New("selection: config manager is nil") + } + if s.supporters == nil { + return errors.New("selection: driver supporter is nil") + } + if s.catalogs == nil { + return errors.New("selection: catalog service is nil") + } + return nil +} diff --git a/internal/config/selection_test.go b/internal/config/state/service_test.go similarity index 81% rename from internal/config/selection_test.go rename to internal/config/state/service_test.go index 418711b2..e19c0a33 100644 --- a/internal/config/selection_test.go +++ b/internal/config/state/service_test.go @@ -1,4 +1,4 @@ -package config +package state import ( "context" @@ -6,21 +6,51 @@ import ( "strings" "testing" + configpkg "neo-code/internal/config" "neo-code/internal/provider" "neo-code/internal/provider/catalog" "neo-code/internal/provider/openaicompat" providertypes "neo-code/internal/provider/types" ) -func TestSelectionServiceListProvidersUsesCatalogModels(t *testing.T) { +const ( + OpenAIName = configpkg.OpenAIName + GeminiName = configpkg.GeminiName + OpenLLName = configpkg.OpenLLName + QiniuName = configpkg.QiniuName + QiniuDefaultModel = configpkg.QiniuDefaultModel + OpenAIDefaultModel = configpkg.OpenAIDefaultModel + ProviderSourceCustom = configpkg.ProviderSourceCustom +) + +func testDefaultConfig() *configpkg.Config { + cfg := configpkg.StaticDefaults() + cfg.Providers = configpkg.DefaultProviders() + if len(cfg.Providers) > 0 { + cfg.SelectedProvider = cfg.Providers[0].Name + cfg.CurrentModel = cfg.Providers[0].Model + } + return cfg +} + +func openAIProviderForTest() configpkg.ProviderConfig { return configpkg.OpenAIProvider() } + +func selectedProviderConfigForTest(cfg configpkg.Config) (configpkg.ProviderConfig, error) { + if strings.TrimSpace(cfg.SelectedProvider) == "" { + return configpkg.ProviderConfig{}, errors.New("selected provider is empty") + } + return cfg.ProviderByName(cfg.SelectedProvider) +} + +func TestSelectionServiceListProviderOptionsUsesCatalogModels(t *testing.T) { t.Parallel() - manager := newSelectionTestManager(t, DefaultConfig()) - service := NewSelectionService(manager, newDriverSupporterStub(), newCatalogStub()) + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, newDriverSupporterStub(), newCatalogStub()) - items, err := service.ListProviders(context.Background()) + items, err := service.ListProviderOptions(context.Background()) if err != nil { - t.Fatalf("ListProviders() error = %v", err) + t.Fatalf("ListProviderOptions() error = %v", err) } expected := map[string]int{ OpenAIName: 2, @@ -50,11 +80,11 @@ func TestSelectionServiceListProvidersUsesCatalogModels(t *testing.T) { func TestSelectionServiceBuiltinUnsupportedAPIStyleFailsAcrossSnapshotPaths(t *testing.T) { t.Parallel() - providerCfg := OpenAIProvider() + providerCfg := openAIProviderForTest() providerCfg.APIStyle = "responses" - defaults := DefaultConfig() - defaults.Providers = []ProviderConfig{providerCfg} + defaults := testDefaultConfig() + defaults.Providers = []configpkg.ProviderConfig{providerCfg} defaults.SelectedProvider = providerCfg.Name defaults.CurrentModel = providerCfg.Model @@ -63,10 +93,10 @@ func TestSelectionServiceBuiltinUnsupportedAPIStyleFailsAcrossSnapshotPaths(t *t if err := registry.Register(openaicompat.Driver()); err != nil { t.Fatalf("register openaicompat driver: %v", err) } - service := NewSelectionService(manager, newDriverSupporterStub(), catalog.NewService("", registry, nil)) + service := NewService(manager, newDriverSupporterStub(), catalog.NewService("", registry, nil)) - if _, err := service.ListProviders(context.Background()); !provider.IsDiscoveryConfigError(err) { - t.Fatalf("expected ListProviders() to surface discovery config error, got %v", err) + if _, err := service.ListProviderOptions(context.Background()); !provider.IsDiscoveryConfigError(err) { + t.Fatalf("expected ListProviderOptions() to surface discovery config error, got %v", err) } if _, err := service.SelectProvider(context.Background(), OpenAIName); !provider.IsDiscoveryConfigError(err) { t.Fatalf("expected SelectProvider() to surface discovery config error, got %v", err) @@ -91,7 +121,7 @@ func TestSelectionServiceListModelsUsesCurrentSelectedProvider(t *testing.T) { t.Parallel() defaults := testDefaultConfig() - defaults.Providers = append(defaults.Providers, ProviderConfig{ + defaults.Providers = append(defaults.Providers, configpkg.ProviderConfig{ Name: "company-gateway", Driver: "openaicompat", BaseURL: "https://llm.example.com/v1", @@ -101,7 +131,7 @@ func TestSelectionServiceListModelsUsesCurrentSelectedProvider(t *testing.T) { defaults.SelectedProvider = "company-gateway" manager := newSelectionTestManager(t, defaults) - service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ listModels: []providertypes.ModelDescriptor{ {ID: "server-coder", Name: "Server Coder"}, }, @@ -119,8 +149,8 @@ func TestSelectionServiceListModelsUsesCurrentSelectedProvider(t *testing.T) { func TestSelectionServiceListModelsSnapshotUsesSnapshotCatalog(t *testing.T) { t.Parallel() - manager := newSelectionTestManager(t, DefaultConfig()) - service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ listModels: []providertypes.ModelDescriptor{ {ID: "sync-model", Name: "Sync Model"}, }, @@ -141,9 +171,9 @@ func TestSelectionServiceListModelsSnapshotUsesSnapshotCatalog(t *testing.T) { func TestSelectionServiceListModelsSnapshotRejectsUnsupportedDriver(t *testing.T) { t.Parallel() - manager := newSelectionTestManager(t, DefaultConfig()) + manager := newSelectionTestManager(t, testDefaultConfig()) supporters := &selectiveDriverSupporter{supported: map[string]bool{"anthropic": true}} - service := NewSelectionService(manager, supporters, newCatalogStub()) + service := NewService(manager, supporters, newCatalogStub()) _, err := service.ListModelsSnapshot(context.Background()) if !errors.Is(err, ErrDriverUnsupported) { @@ -155,16 +185,16 @@ func TestSelectionServiceListModelsSnapshotRejectsUnsupportedDriver(t *testing.T } func TestSelectionServiceSelectProviderAndSetCurrentModel(t *testing.T) { - manager := newSelectionTestManager(t, DefaultConfig()) + manager := newSelectionTestManager(t, testDefaultConfig()) - if err := manager.Update(context.Background(), func(cfg *Config) error { + if err := manager.Update(context.Background(), func(cfg *configpkg.Config) error { cfg.CurrentModel = "gpt-5.4" return nil }); err != nil { t.Fatalf("seed current model: %v", err) } - service := NewSelectionService(manager, newDriverSupporterStub(), newCatalogStub()) + service := NewService(manager, newDriverSupporterStub(), newCatalogStub()) selection, err := service.SelectProvider(context.Background(), QiniuName) if err != nil { @@ -186,14 +216,14 @@ func TestSelectionServiceSelectProviderAndSetCurrentModel(t *testing.T) { if !errors.Is(err, ErrModelNotFound) { t.Fatalf("expected ErrModelNotFound, got %v", err) } - if selection != (ProviderSelection{}) { + if selection != (Selection{}) { t.Fatalf("expected failed switch to return empty selection, got %+v", selection) } cfg := manager.Get() - selected, err := cfg.SelectedProviderConfig() + selected, err := selectedProviderConfigForTest(cfg) if err != nil { - t.Fatalf("SelectedProviderConfig() error = %v", err) + t.Fatalf("selectedProviderConfig() error = %v", err) } if selected.Name != QiniuName { t.Fatalf("expected selected provider %q, got %+v", QiniuName, selected) @@ -206,9 +236,9 @@ func TestSelectionServiceSelectProviderAndSetCurrentModel(t *testing.T) { func TestSelectionServiceSetCurrentModelRejectsUnsupportedDriver(t *testing.T) { t.Parallel() - manager := newSelectionTestManager(t, DefaultConfig()) + manager := newSelectionTestManager(t, testDefaultConfig()) supporters := &selectiveDriverSupporter{supported: map[string]bool{"anthropic": true}} - service := NewSelectionService(manager, supporters, newCatalogStub()) + service := NewService(manager, supporters, newCatalogStub()) _, err := service.SetCurrentModel(context.Background(), OpenAIDefaultModel) if !errors.Is(err, ErrDriverUnsupported) { @@ -219,8 +249,8 @@ func TestSelectionServiceSetCurrentModelRejectsUnsupportedDriver(t *testing.T) { func TestSelectionServiceSelectProviderRequiresDiscoveryOnCacheMiss(t *testing.T) { t.Parallel() - defaults := DefaultConfig() - defaults.Providers = append(defaults.Providers, ProviderConfig{ + defaults := testDefaultConfig() + defaults.Providers = append(defaults.Providers, configpkg.ProviderConfig{ Name: "company-gateway", Driver: "openaicompat", BaseURL: "https://llm.example.com/v1", @@ -230,7 +260,7 @@ func TestSelectionServiceSelectProviderRequiresDiscoveryOnCacheMiss(t *testing.T tracker := &catalogMethodCalls{} manager := newSelectionTestManager(t, defaults) - service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ listModels: []providertypes.ModelDescriptor{ {ID: "server-coder", Name: "Server Coder"}, {ID: "server-chat", Name: "Server Chat"}, @@ -254,8 +284,8 @@ func TestSelectionServiceSelectBuiltinProviderUsesSnapshotCatalog(t *testing.T) t.Parallel() tracker := &catalogMethodCalls{} - manager := newSelectionTestManager(t, DefaultConfig()) - service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ listErr: errors.New("unexpected sync discovery"), snapshotModels: []providertypes.ModelDescriptor{ {ID: QiniuDefaultModel, Name: QiniuDefaultModel}, @@ -279,15 +309,15 @@ func TestSelectionServiceSelectBuiltinProviderUsesSnapshotCatalog(t *testing.T) func TestSelectionServiceEnsureSelectionRepairsInvalidCurrentModel(t *testing.T) { t.Parallel() - manager := newSelectionTestManager(t, DefaultConfig()) - if err := manager.Update(context.Background(), func(cfg *Config) error { + manager := newSelectionTestManager(t, testDefaultConfig()) + if err := manager.Update(context.Background(), func(cfg *configpkg.Config) error { cfg.CurrentModel = "unsupported-current" return nil }); err != nil { t.Fatalf("seed invalid current model: %v", err) } - service := NewSelectionService(manager, newDriverSupporterStub(), newCatalogStub()) + service := NewService(manager, newDriverSupporterStub(), newCatalogStub()) selection, err := service.EnsureSelection(context.Background()) if err != nil { @@ -310,7 +340,7 @@ func TestSelectionServiceEnsureSelectionRejectsUnsupportedSelectedProvider(t *te t.Parallel() defaults := testDefaultConfig() - defaults.Providers = append(defaults.Providers, ProviderConfig{ + defaults.Providers = append(defaults.Providers, configpkg.ProviderConfig{ Name: "company-gateway", Driver: "anthropic", BaseURL: "https://llm.example.com/v1", @@ -322,7 +352,7 @@ func TestSelectionServiceEnsureSelectionRejectsUnsupportedSelectedProvider(t *te manager := newSelectionTestManager(t, defaults) supporters := &selectiveDriverSupporter{supported: map[string]bool{"openaicompat": true}} - service := NewSelectionService(manager, supporters, newCatalogStub()) + service := NewService(manager, supporters, newCatalogStub()) _, err := service.EnsureSelection(context.Background()) if !errors.Is(err, ErrDriverUnsupported) { @@ -342,7 +372,7 @@ func TestSelectionServiceEnsureSelectionFallsBackToFirstDiscoveredModel(t *testi t.Parallel() defaults := testDefaultConfig() - defaults.Providers = append(defaults.Providers, ProviderConfig{ + defaults.Providers = append(defaults.Providers, configpkg.ProviderConfig{ Name: "company-gateway", Driver: "openaicompat", BaseURL: "https://llm.example.com/v1", @@ -353,7 +383,7 @@ func TestSelectionServiceEnsureSelectionFallsBackToFirstDiscoveredModel(t *testi defaults.CurrentModel = "unknown-model" manager := newSelectionTestManager(t, defaults) - service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ snapshotModels: []providertypes.ModelDescriptor{ {ID: "server-coder", Name: "Server Coder"}, {ID: "server-chat", Name: "Server Chat"}, @@ -377,7 +407,7 @@ func TestSelectionServiceEnsureSelectionFallsBackToBuiltinDefaultModelWhenSnapsh tracker := &catalogMethodCalls{} manager := newSelectionTestManager(t, defaults) - service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{tracker: tracker}) + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{tracker: tracker}) selection, err := service.EnsureSelection(context.Background()) if err != nil { @@ -403,7 +433,7 @@ func TestSelectionServiceEnsureSelectionKeepsCustomSelectionWhenSnapshotMissing( t.Parallel() defaults := testDefaultConfig() - defaults.Providers = append(defaults.Providers, ProviderConfig{ + defaults.Providers = append(defaults.Providers, configpkg.ProviderConfig{ Name: "company-gateway", Driver: "openaicompat", BaseURL: "https://llm.example.com/v1", @@ -415,7 +445,7 @@ func TestSelectionServiceEnsureSelectionKeepsCustomSelectionWhenSnapshotMissing( tracker := &catalogMethodCalls{} manager := newSelectionTestManager(t, defaults) - service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{tracker: tracker}) + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{tracker: tracker}) selection, err := service.EnsureSelection(context.Background()) if err != nil { @@ -441,7 +471,7 @@ func TestSelectionServiceEnsureSelectionBackfillsEmptyCustomModelFromSynchronous t.Parallel() defaults := testDefaultConfig() - defaults.Providers = append(defaults.Providers, ProviderConfig{ + defaults.Providers = append(defaults.Providers, configpkg.ProviderConfig{ Name: "company-gateway", Driver: "openaicompat", BaseURL: "https://llm.example.com/v1", @@ -453,7 +483,7 @@ func TestSelectionServiceEnsureSelectionBackfillsEmptyCustomModelFromSynchronous tracker := &catalogMethodCalls{} manager := newSelectionTestManager(t, defaults) - service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ listModels: []providertypes.ModelDescriptor{ {ID: "server-coder", Name: "Server Coder"}, {ID: "server-chat", Name: "Server Chat"}, @@ -485,7 +515,7 @@ func TestSelectionServiceEnsureSelectionKeepsEmptyCustomModelWhenSynchronousDisc t.Parallel() defaults := testDefaultConfig() - defaults.Providers = append(defaults.Providers, ProviderConfig{ + defaults.Providers = append(defaults.Providers, configpkg.ProviderConfig{ Name: "company-gateway", Driver: "openaicompat", BaseURL: "https://llm.example.com/v1", @@ -497,7 +527,7 @@ func TestSelectionServiceEnsureSelectionKeepsEmptyCustomModelWhenSynchronousDisc tracker := &catalogMethodCalls{} manager := newSelectionTestManager(t, defaults) - service := NewSelectionService(manager, newDriverSupporterStub(), catalogMethodsStub{ + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ listErr: errors.New("discover failed"), tracker: tracker, }) @@ -525,8 +555,8 @@ func TestSelectionServiceEnsureSelectionKeepsEmptyCustomModelWhenSynchronousDisc func TestSelectionServiceSelectCustomProviderDoesNotPersistWhenDiscoveryFails(t *testing.T) { t.Parallel() - defaults := DefaultConfig() - defaults.Providers = append(defaults.Providers, ProviderConfig{ + defaults := testDefaultConfig() + defaults.Providers = append(defaults.Providers, configpkg.ProviderConfig{ Name: "company-gateway", Driver: "openaicompat", BaseURL: "https://llm.example.com/v1", @@ -535,7 +565,7 @@ func TestSelectionServiceSelectCustomProviderDoesNotPersistWhenDiscoveryFails(t }) manager := newSelectionTestManager(t, defaults) - service := NewSelectionService(manager, newDriverSupporterStub(), errorCatalogStub{err: errors.New("discover failed")}) + service := NewService(manager, newDriverSupporterStub(), errorCatalogStub{err: errors.New("discover failed")}) _, err := service.SelectProvider(context.Background(), "company-gateway") if err == nil || !strings.Contains(err.Error(), "discover failed") { @@ -554,9 +584,9 @@ func TestSelectionServiceSelectCustomProviderDoesNotPersistWhenDiscoveryFails(t func TestSelectionServiceSelectProviderRejectsUnsupportedDriver(t *testing.T) { t.Parallel() - manager := newSelectionTestManager(t, DefaultConfig()) + manager := newSelectionTestManager(t, testDefaultConfig()) supporters := &selectiveDriverSupporter{supported: map[string]bool{"anthropic": true}} - service := NewSelectionService(manager, supporters, newCatalogStub()) + service := NewService(manager, supporters, newCatalogStub()) if _, err := service.SelectProvider(context.Background(), OpenAIName); !errors.Is(err, ErrDriverUnsupported) { t.Fatalf("expected SelectProvider() to preserve driver error, got %v", err) @@ -570,7 +600,7 @@ func TestSelectionServiceValidateErrors(t *testing.T) { tests := []struct { name string - service *SelectionService + service *Service errMsg string }{ { @@ -580,7 +610,7 @@ func TestSelectionServiceValidateErrors(t *testing.T) { }, { name: "nil manager", - service: &SelectionService{ + service: &Service{ manager: nil, supporters: &driverSupporterAll{}, catalogs: newCatalogStub(), @@ -589,8 +619,8 @@ func TestSelectionServiceValidateErrors(t *testing.T) { }, { name: "nil supporters", - service: &SelectionService{ - manager: NewManager(NewLoader(t.TempDir(), DefaultConfig())), + service: &Service{ + manager: configpkg.NewManager(configpkg.NewLoader(t.TempDir(), testDefaultConfig())), supporters: nil, catalogs: newCatalogStub(), }, @@ -598,8 +628,8 @@ func TestSelectionServiceValidateErrors(t *testing.T) { }, { name: "nil catalogs", - service: &SelectionService{ - manager: NewManager(NewLoader(t.TempDir(), DefaultConfig())), + service: &Service{ + manager: configpkg.NewManager(configpkg.NewLoader(t.TempDir(), testDefaultConfig())), supporters: &driverSupporterAll{}, catalogs: nil, }, @@ -611,7 +641,7 @@ func TestSelectionServiceValidateErrors(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - _, err := tt.service.ListProviders(context.Background()) + _, err := tt.service.ListProviderOptions(context.Background()) if err == nil || err.Error() != tt.errMsg { t.Fatalf("expected error %q, got %v", tt.errMsg, err) } @@ -622,8 +652,8 @@ func TestSelectionServiceValidateErrors(t *testing.T) { func TestSelectionServiceOperationsWithCanceledContext(t *testing.T) { t.Parallel() - manager := newSelectionTestManager(t, DefaultConfig()) - service := NewSelectionService(manager, newDriverSupporterStub(), newCatalogStub()) + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, newDriverSupporterStub(), newCatalogStub()) ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -633,9 +663,9 @@ func TestSelectionServiceOperationsWithCanceledContext(t *testing.T) { fn func(context.Context) error }{ { - name: "ListProviders", + name: "ListProviderOptions", fn: func(ctx context.Context) error { - _, err := service.ListProviders(ctx) + _, err := service.ListProviderOptions(ctx) return err }, }, @@ -680,8 +710,8 @@ func TestSelectionServiceOperationsWithCanceledContext(t *testing.T) { func TestSelectionServiceSetCurrentModelEmptyModelID(t *testing.T) { t.Parallel() - manager := newSelectionTestManager(t, DefaultConfig()) - service := NewSelectionService(manager, newDriverSupporterStub(), newCatalogStub()) + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, newDriverSupporterStub(), newCatalogStub()) _, err := service.SetCurrentModel(context.Background(), "") if !errors.Is(err, ErrModelNotFound) { @@ -862,10 +892,10 @@ func defaultModelsForInput(input provider.CatalogInput) []providertypes.ModelDes func newCatalogStub() ModelCatalog { return catalogStub{} } func newDriverSupporterStub() DriverSupporter { return &driverSupporterAll{} } -func newSelectionTestManager(t *testing.T, defaults *Config) *Manager { +func newSelectionTestManager(t *testing.T, defaults *configpkg.Config) *configpkg.Manager { t.Helper() - manager := NewManager(NewLoader(t.TempDir(), defaults)) + manager := configpkg.NewManager(configpkg.NewLoader(t.TempDir(), defaults)) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("load config: %v", err) } diff --git a/internal/context/compact/runner.go b/internal/context/compact/runner.go index dffa4bcd..933713e8 100644 --- a/internal/context/compact/runner.go +++ b/internal/context/compact/runner.go @@ -207,7 +207,7 @@ func (s *Service) transcriptStore() transcriptStore { // normalizeCompactConfig 在 compact 执行前补齐缺失配置并收敛默认策略。 func normalizeCompactConfig(cfg config.CompactConfig) config.CompactConfig { - defaults := config.Default().Context.Compact + defaults := config.StaticDefaults().Context.Compact cfg.ApplyDefaults(defaults) if strings.TrimSpace(cfg.ManualStrategy) == "" { cfg.ManualStrategy = config.CompactManualStrategyKeepRecent diff --git a/internal/context/test_helpers_test.go b/internal/context/test_helpers_test.go index 4186cb2f..bb93e4b0 100644 --- a/internal/context/test_helpers_test.go +++ b/internal/context/test_helpers_test.go @@ -3,11 +3,18 @@ package context import "neo-code/internal/config" func testMetadata(workdir string) Metadata { - cfg := config.DefaultConfig() + cfg := config.StaticDefaults() + providers := config.DefaultProviders() + providerName := "" + modelID := "" + if len(providers) > 0 { + providerName = providers[0].Name + modelID = providers[0].Model + } return Metadata{ Workdir: workdir, Shell: cfg.Shell, - Provider: cfg.SelectedProvider, - Model: cfg.CurrentModel, + Provider: providerName, + Model: modelID, } } diff --git a/internal/provider/catalog/additional_test.go b/internal/provider/catalog/additional_test.go index ffdd813b..e2f50ade 100644 --- a/internal/provider/catalog/additional_test.go +++ b/internal/provider/catalog/additional_test.go @@ -129,10 +129,7 @@ func TestCatalogSnapshotOnMissingCatalog(t *testing.T) { t.Parallel() service := NewService("", provider.NewRegistry(), newMemoryStore()) - input, err := config.NewProviderCatalogInput(config.OpenAIProvider()) - if err != nil { - t.Fatalf("NewProviderCatalogInput() error = %v", err) - } + input := mustCatalogInput(t, config.OpenAIProvider()) snapshot := service.catalogSnapshot(context.Background(), input) if snapshot.ok || snapshot.expired || len(snapshot.models) != 0 { t.Fatalf("expected empty snapshot on cache miss, got %+v", snapshot) @@ -151,10 +148,7 @@ func TestListProviderModelsRejectsUnsupportedOpenAICompatibleAPIStyle(t *testing cfg := customGatewayProvider() cfg.APIStyle = "responses" - input, err := config.NewProviderCatalogInput(cfg) - if err != nil { - t.Fatalf("NewProviderCatalogInput() error = %v", err) - } + input := mustCatalogInput(t, cfg) models, err := service.ListProviderModels(context.Background(), input) if err == nil || models != nil || !strings.Contains(err.Error(), `api_style "responses" is not supported yet`) { @@ -174,10 +168,7 @@ func TestListBuiltinProviderModelsRejectsUnsupportedOpenAICompatibleAPIStyle(t * cfg := config.OpenAIProvider() cfg.APIStyle = "responses" - input, err := config.NewProviderCatalogInput(cfg) - if err != nil { - t.Fatalf("NewProviderCatalogInput() error = %v", err) - } + input := mustCatalogInput(t, cfg) models, err := service.ListProviderModels(context.Background(), input) if err == nil || models != nil || !strings.Contains(err.Error(), `api_style "responses" is not supported yet`) { @@ -197,10 +188,7 @@ func TestListBuiltinProviderModelsSnapshotRejectsUnsupportedOpenAICompatibleAPIS cfg := config.OpenAIProvider() cfg.APIStyle = "responses" - input, err := config.NewProviderCatalogInput(cfg) - if err != nil { - t.Fatalf("NewProviderCatalogInput() error = %v", err) - } + input := mustCatalogInput(t, cfg) models, err := service.ListProviderModelsSnapshot(context.Background(), input) if err == nil || models != nil || !provider.IsDiscoveryConfigError(err) { @@ -224,10 +212,7 @@ func TestListBuiltinProviderModelsSnapshotAndCachedRejectUnsupportedAPIStyleWith cfg := config.OpenAIProvider() cfg.APIStyle = "responses" - input, err := config.NewProviderCatalogInput(cfg) - if err != nil { - t.Fatalf("NewProviderCatalogInput() error = %v", err) - } + input := mustCatalogInput(t, cfg) if err := store.Save(context.Background(), ModelCatalog{ SchemaVersion: schemaVersion, Identity: input.Identity, diff --git a/internal/provider/catalog/service_test.go b/internal/provider/catalog/service_test.go index 473ee9e5..df826fc7 100644 --- a/internal/provider/catalog/service_test.go +++ b/internal/provider/catalog/service_test.go @@ -81,10 +81,7 @@ func TestListProviderModelsMergesConfiguredMetadataAfterDiscovery(t *testing.T) }} providerCfg.Model = "deepseek-coder" - input, err := config.NewProviderCatalogInput(providerCfg) - if err != nil { - t.Fatalf("NewProviderCatalogInput() error = %v", err) - } + input := mustCatalogInput(t, providerCfg) models, err := service.ListProviderModels(context.Background(), input) if err != nil { t.Fatalf("ListProviderModels() error = %v", err) @@ -385,10 +382,7 @@ func TestDiscoverAndPersistFailurePaths(t *testing.T) { APIKeyEnv: "", } - input, err := config.NewProviderCatalogInput(providerCfg) - if err != nil { - t.Fatalf("NewProviderCatalogInput() error = %v", err) - } + input := mustCatalogInput(t, providerCfg) discovered, err := service.discoverAndPersist(context.Background(), input) if err == nil || discovered != nil { t.Fatalf("expected resolve failure to surface as error, got err=%v models=%+v", err, discovered) @@ -533,18 +527,40 @@ func newRegistry(t *testing.T, name string, discover provider.DiscoveryFunc) *pr } func openAIProviderSource() provider.CatalogInput { - input, err := config.NewProviderCatalogInput(config.OpenAIProvider()) - if err != nil { - panic(err) - } - return input + return mustCatalogInput(nil, config.OpenAIProvider()) } func customGatewayProviderSource() provider.CatalogInput { - input, err := config.NewProviderCatalogInput(customGatewayProvider()) + return mustCatalogInput(nil, customGatewayProvider()) +} + +func mustCatalogInput(t *testing.T, cfg config.ProviderConfig) provider.CatalogInput { + cloned := cfg + cloned.Models = providertypes.CloneModelDescriptors(cfg.Models) + + identity, err := cloned.Identity() if err != nil { + if t != nil { + t.Helper() + t.Fatalf("Identity() error = %v", err) + } panic(err) } + + input := provider.CatalogInput{ + Identity: identity, + ConfiguredModels: providertypes.CloneModelDescriptors(cloned.Models), + ResolveDiscoveryConfig: func() (provider.RuntimeConfig, error) { + resolved, err := cloned.Resolve() + if err != nil { + return provider.RuntimeConfig{}, err + } + return resolved.ToRuntimeConfig(), nil + }, + } + if cloned.Source != config.ProviderSourceCustom { + input.DefaultModels = providertypes.DescriptorsFromIDs([]string{cloned.Model}) + } return input } diff --git a/internal/runtime/compact.go b/internal/runtime/compact.go index 1be774ea..210af144 100644 --- a/internal/runtime/compact.go +++ b/internal/runtime/compact.go @@ -128,9 +128,6 @@ func (s *Service) runCompactForSession( if result.Applied { session.Messages = append([]providertypes.Message(nil), result.Messages...) - // Reset token totals now so the persisted session never carries stale high - // counts; if the follow-up provider call is canceled before its own save - // the next run will start from zero rather than immediately auto-compacting. session.TokenInputTotal = 0 session.TokenOutputTotal = 0 session.UpdatedAt = time.Now() @@ -181,7 +178,7 @@ func (s *Service) defaultCompactRunner(session agentsession.Session, cfg config. return contextcompact.NewRunner(newCompactSummaryGenerator(s.providerFactory, resolvedProvider.ToRuntimeConfig(), model)), nil } -// resolveCompactProviderSelection 优先复用会话记录的 provider/model,缺失时再回退当前配置。 +// resolveCompactProviderSelection 优先复用会话记录的 provider/model,缺失时回退到当前配置。 func resolveCompactProviderSelection(session agentsession.Session, cfg config.Config) (config.ResolvedProviderConfig, string, error) { sessionProvider := strings.TrimSpace(session.Provider) sessionModel := strings.TrimSpace(session.Model) @@ -197,18 +194,9 @@ func resolveCompactProviderSelection(session agentsession.Session, cfg config.Co return resolved, sessionModel, nil } - resolved, err := resolveSelectedProviderFromConfig(cfg) + resolved, err := config.ResolveSelectedProvider(cfg) if err != nil { return config.ResolvedProviderConfig{}, "", err } return resolved, strings.TrimSpace(cfg.CurrentModel), nil } - -// resolveSelectedProviderFromConfig 统一解析当前选中的 provider 配置并补全密钥。 -func resolveSelectedProviderFromConfig(cfg config.Config) (config.ResolvedProviderConfig, error) { - providerCfg, err := cfg.SelectedProviderConfig() - if err != nil { - return config.ResolvedProviderConfig{}, err - } - return providerCfg.Resolve() -} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index bdbdf14a..f485cc47 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -718,7 +718,8 @@ func (s *Service) callProviderWithRetry( } } - resolvedProvider, err := s.configManager.ResolvedSelectedProvider() + cfg := s.configManager.Get() + resolvedProvider, err := config.ResolveSelectedProvider(cfg) if err != nil { return nil, err } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 591b9afa..f3b71720 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -2556,7 +2556,12 @@ func newRuntimeConfigManagerWithProviderEnvs(t *testing.T, providerEnvs map[stri t.Fatalf("set env: %v", err) } - defaults := config.DefaultConfig() + defaults := config.StaticDefaults() + defaults.Providers = config.DefaultProviders() + if len(defaults.Providers) > 0 { + defaults.SelectedProvider = defaults.Providers[0].Name + defaults.CurrentModel = defaults.Providers[0].Model + } selected := provider.NormalizeKey(defaults.SelectedProvider) for i := range defaults.Providers { if provider.NormalizeKey(defaults.Providers[i].Name) == selected { diff --git a/internal/tui/bootstrap/builder.go b/internal/tui/bootstrap/builder.go index 630a06ca..017d0915 100644 --- a/internal/tui/bootstrap/builder.go +++ b/internal/tui/bootstrap/builder.go @@ -5,17 +5,18 @@ import ( "fmt" "neo-code/internal/config" + configstate "neo-code/internal/config/state" providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" ) // ProviderService 定义 TUI 需要注入的 provider 交互能力。 type ProviderService interface { - ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) - SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) + ListProviderOptions(ctx context.Context) ([]configstate.ProviderOption, error) + SelectProvider(ctx context.Context, providerID string) (configstate.Selection, error) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) - SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) + SetCurrentModel(ctx context.Context, modelID string) (configstate.Selection, error) } // Options 定义 bootstrap 装配输入。 diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go index deeb87e2..e4ebc252 100644 --- a/internal/tui/bootstrap/builder_test.go +++ b/internal/tui/bootstrap/builder_test.go @@ -6,6 +6,7 @@ import ( "testing" "neo-code/internal/config" + configstate "neo-code/internal/config/state" providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" agentsession "neo-code/internal/session" @@ -49,12 +50,12 @@ func (r *testRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, w type testProviderService struct{} -func (s *testProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { +func (s *testProviderService) ListProviderOptions(ctx context.Context) ([]configstate.ProviderOption, error) { return nil, nil } -func (s *testProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { - return config.ProviderSelection{}, nil +func (s *testProviderService) SelectProvider(ctx context.Context, providerID string) (configstate.Selection, error) { + return configstate.Selection{}, nil } func (s *testProviderService) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { @@ -65,8 +66,8 @@ func (s *testProviderService) ListModelsSnapshot(ctx context.Context) ([]provide return nil, nil } -func (s *testProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { - return config.ProviderSelection{}, nil +func (s *testProviderService) SetCurrentModel(ctx context.Context, modelID string) (configstate.Selection, error) { + return configstate.Selection{}, nil } func TestBuild(t *testing.T) { @@ -232,12 +233,12 @@ func (r noopRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, wo type noopProviderService struct{} -func (s noopProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { +func (s noopProviderService) ListProviderOptions(ctx context.Context) ([]configstate.ProviderOption, error) { return nil, nil } -func (s noopProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { - return config.ProviderSelection{}, nil +func (s noopProviderService) SelectProvider(ctx context.Context, providerID string) (configstate.Selection, error) { + return configstate.Selection{}, nil } func (s noopProviderService) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { @@ -248,8 +249,8 @@ func (s noopProviderService) ListModelsSnapshot(ctx context.Context) ([]provider return nil, nil } -func (s noopProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { - return config.ProviderSelection{}, nil +func (s noopProviderService) SetCurrentModel(ctx context.Context, modelID string) (configstate.Selection, error) { + return configstate.Selection{}, nil } func TestBuildFactoryErrors(t *testing.T) { diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 9e813f39..cb74712a 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -15,6 +15,7 @@ import ( "github.com/charmbracelet/lipgloss" "neo-code/internal/config" + configstate "neo-code/internal/config/state" providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" tuibootstrap "neo-code/internal/tui/bootstrap" @@ -51,11 +52,11 @@ type workspaceCommandResultMsg = tuistate.WorkspaceCommandResultMsg type permissionResolutionFinishedMsg = tuistate.PermissionResolutionFinishedMsg type ProviderController interface { - ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) - SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) + ListProviderOptions(ctx context.Context) ([]configstate.ProviderOption, error) + SelectProvider(ctx context.Context, providerID string) (configstate.Selection, error) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) - SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) + SetCurrentModel(ctx context.Context, modelID string) (configstate.Selection, error) } // appServices 聚合 App 需要的服务依赖,避免与渲染状态混在同一层级。 diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index ac954835..16a26726 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -9,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "neo-code/internal/config" + configstate "neo-code/internal/config/state" providertypes "neo-code/internal/provider/types" tuicommands "neo-code/internal/tui/core/commands" tuistatus "neo-code/internal/tui/core/status" @@ -164,7 +165,7 @@ func newHelpPickerItems(items []selectionItem) list.Model { return newHelpPicker(listItems) } -func mapProviderItems(items []config.ProviderCatalogItem) []selectionItem { +func mapProviderItems(items []configstate.ProviderOption) []selectionItem { mapped := make([]selectionItem, 0, len(items)) for _, item := range items { mapped = append(mapped, selectionItem{ @@ -201,7 +202,7 @@ func replaceHelpPickerItems(current *list.Model, items []selectionItem) { } func (a *App) refreshProviderPicker() error { - items, err := a.providerSvc.ListProviders(context.Background()) + items, err := a.providerSvc.ListProviderOptions(context.Background()) if err != nil { return err } @@ -296,7 +297,7 @@ func runProviderSelection(providerSvc ProviderController, providerName string) t return tuiservices.SelectProviderCmd( providerSvc, providerName, - func(selection config.ProviderSelection, err error) tea.Msg { + func(selection configstate.Selection, err error) tea.Msg { if err != nil { return localCommandResultMsg{Err: err} } @@ -312,7 +313,7 @@ func runModelSelection(providerSvc ProviderController, modelID string) tea.Cmd { return tuiservices.SelectModelCmd( providerSvc, modelID, - func(selection config.ProviderSelection, err error) tea.Msg { + func(selection configstate.Selection, err error) tea.Msg { if err != nil { return localCommandResultMsg{Err: err} } diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index b2a9ab82..34c5c2bc 100644 --- a/internal/tui/core/app/commands_test.go +++ b/internal/tui/core/app/commands_test.go @@ -8,7 +8,7 @@ import ( "github.com/charmbracelet/bubbles/list" - "neo-code/internal/config" + configstate "neo-code/internal/config/state" providertypes "neo-code/internal/provider/types" tuistatus "neo-code/internal/tui/core/status" ) @@ -155,12 +155,12 @@ type errorProviderService struct { err error } -func (s errorProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { +func (s errorProviderService) ListProviderOptions(ctx context.Context) ([]configstate.ProviderOption, error) { return nil, s.err } -func (s errorProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { - return config.ProviderSelection{}, s.err +func (s errorProviderService) SelectProvider(ctx context.Context, providerID string) (configstate.Selection, error) { + return configstate.Selection{}, s.err } func (s errorProviderService) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { @@ -171,8 +171,8 @@ func (s errorProviderService) ListModelsSnapshot(ctx context.Context) ([]provide return nil, s.err } -func (s errorProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { - return config.ProviderSelection{}, s.err +func (s errorProviderService) SetCurrentModel(ctx context.Context, modelID string) (configstate.Selection, error) { + return configstate.Selection{}, s.err } func TestExecuteLocalCommandErrors(t *testing.T) { diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index a5d47680..e2f3aea9 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -9,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "neo-code/internal/config" + configstate "neo-code/internal/config/state" providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" agentsession "neo-code/internal/session" @@ -19,20 +20,20 @@ import ( ) type stubProviderService struct { - providers []config.ProviderCatalogItem + providers []configstate.ProviderOption models []providertypes.ModelDescriptor } -func (s stubProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { +func (s stubProviderService) ListProviderOptions(ctx context.Context) ([]configstate.ProviderOption, error) { return s.providers, nil } -func (s stubProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { +func (s stubProviderService) SelectProvider(ctx context.Context, providerID string) (configstate.Selection, error) { modelID := "" if len(s.models) > 0 { modelID = s.models[0].ID } - return config.ProviderSelection{ProviderID: providerID, ModelID: modelID}, nil + return configstate.Selection{ProviderID: providerID, ModelID: modelID}, nil } func (s stubProviderService) ListModels(ctx context.Context) ([]providertypes.ModelDescriptor, error) { @@ -43,12 +44,12 @@ func (s stubProviderService) ListModelsSnapshot(ctx context.Context) ([]provider return s.models, nil } -func (s stubProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { +func (s stubProviderService) SetCurrentModel(ctx context.Context, modelID string) (configstate.Selection, error) { providerID := "" if len(s.providers) > 0 { providerID = s.providers[0].ID } - return config.ProviderSelection{ProviderID: providerID, ModelID: modelID}, nil + return configstate.Selection{ProviderID: providerID, ModelID: modelID}, nil } type stubRuntime struct { @@ -96,26 +97,32 @@ func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, w return agentsession.NewWithWorkdir("draft", workdir), nil } -func newTestApp(t *testing.T) (App, *stubRuntime) { - t.Helper() - - cfg := config.DefaultConfig() - cfg.Workdir = t.TempDir() +func newDefaultAppConfig() *config.Config { + cfg := config.StaticDefaults() + cfg.Providers = config.DefaultProviders() if len(cfg.Providers) > 0 { cfg.SelectedProvider = cfg.Providers[0].Name cfg.CurrentModel = cfg.Providers[0].Model } + return cfg +} + +func newTestApp(t *testing.T) (App, *stubRuntime) { + t.Helper() + + cfg := newDefaultAppConfig() + cfg.Workdir = t.TempDir() manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg)) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("Load() error = %v", err) } - var providers []config.ProviderCatalogItem + var providers []configstate.ProviderOption var models []providertypes.ModelDescriptor if len(cfg.Providers) > 0 { provider := cfg.Providers[0] - providers = []config.ProviderCatalogItem{ + providers = []configstate.ProviderOption{ { ID: provider.Name, Name: provider.Name, @@ -787,7 +794,7 @@ func TestHelpHeightAndRenderHelp(t *testing.T) { } func TestNewWithBootstrapSuccess(t *testing.T) { - cfg := config.DefaultConfig() + cfg := newDefaultAppConfig() cfg.Workdir = t.TempDir() if len(cfg.Providers) > 0 { cfg.SelectedProvider = cfg.Providers[0].Name @@ -799,11 +806,11 @@ func TestNewWithBootstrapSuccess(t *testing.T) { t.Fatalf("Load() error = %v", err) } - var providers []config.ProviderCatalogItem + var providers []configstate.ProviderOption var models []providertypes.ModelDescriptor if len(cfg.Providers) > 0 { provider := cfg.Providers[0] - providers = []config.ProviderCatalogItem{ + providers = []configstate.ProviderOption{ { ID: provider.Name, Name: provider.Name, @@ -833,7 +840,7 @@ func TestNewWithBootstrapSuccess(t *testing.T) { } func TestNewWithBootstrapMissingDependencies(t *testing.T) { - cfg := config.DefaultConfig() + cfg := newDefaultAppConfig() manager := config.NewManager(config.NewLoader(t.TempDir(), cfg)) if _, err := manager.Load(context.Background()); err != nil { @@ -860,7 +867,7 @@ func TestNewWithBootstrapMissingDependencies(t *testing.T) { } func TestNewUsesBootstrap(t *testing.T) { - cfg := config.DefaultConfig() + cfg := newDefaultAppConfig() cfg.Workdir = t.TempDir() if len(cfg.Providers) > 0 { cfg.SelectedProvider = cfg.Providers[0].Name @@ -872,11 +879,11 @@ func TestNewUsesBootstrap(t *testing.T) { t.Fatalf("Load() error = %v", err) } - var providers []config.ProviderCatalogItem + var providers []configstate.ProviderOption var models []providertypes.ModelDescriptor if len(cfg.Providers) > 0 { provider := cfg.Providers[0] - providers = []config.ProviderCatalogItem{ + providers = []configstate.ProviderOption{ { ID: provider.Name, Name: provider.Name, diff --git a/internal/tui/services/provider_service.go b/internal/tui/services/provider_service.go index 69a5ccc4..8fa4ebcb 100644 --- a/internal/tui/services/provider_service.go +++ b/internal/tui/services/provider_service.go @@ -6,18 +6,18 @@ import ( tea "github.com/charmbracelet/bubbletea" - "neo-code/internal/config" + configstate "neo-code/internal/config/state" providertypes "neo-code/internal/provider/types" ) // ProviderSelector 定义 provider 选择命令所需最小能力。 type ProviderSelector interface { - SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) + SelectProvider(ctx context.Context, providerID string) (configstate.Selection, error) } // ModelSelector 定义 model 选择命令所需最小能力。 type ModelSelector interface { - SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) + SetCurrentModel(ctx context.Context, modelID string) (configstate.Selection, error) } // ModelCatalogReader 定义当前 provider 模型目录刷新所需最小能力。 @@ -29,7 +29,7 @@ type ModelCatalogReader interface { func SelectProviderCmd( providerSvc ProviderSelector, providerID string, - toMsg func(config.ProviderSelection, error) tea.Msg, + toMsg func(configstate.Selection, error) tea.Msg, ) tea.Cmd { return func() tea.Msg { selection, err := providerSvc.SelectProvider(context.Background(), providerID) @@ -41,7 +41,7 @@ func SelectProviderCmd( func SelectModelCmd( providerSvc ModelSelector, modelID string, - toMsg func(config.ProviderSelection, error) tea.Msg, + toMsg func(configstate.Selection, error) tea.Msg, ) tea.Cmd { return func() tea.Msg { selection, err := providerSvc.SetCurrentModel(context.Background(), modelID) diff --git a/internal/tui/services/services_test.go b/internal/tui/services/services_test.go index fab57501..46ff30e4 100644 --- a/internal/tui/services/services_test.go +++ b/internal/tui/services/services_test.go @@ -10,7 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" - "neo-code/internal/config" + configstate "neo-code/internal/config/state" providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" ) @@ -49,16 +49,16 @@ func (s *stubPermissionResolver) ResolvePermission(ctx context.Context, input ag } type stubProvider struct { - selection config.ProviderSelection + selection configstate.Selection models []providertypes.ModelDescriptor err error } -func (s *stubProvider) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { +func (s *stubProvider) SelectProvider(ctx context.Context, providerID string) (configstate.Selection, error) { return s.selection, s.err } -func (s *stubProvider) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { +func (s *stubProvider) SetCurrentModel(ctx context.Context, modelID string) (configstate.Selection, error) { return s.selection, s.err } @@ -156,17 +156,17 @@ func TestRunResolvePermissionCmd(t *testing.T) { func TestProviderCmds(t *testing.T) { svc := &stubProvider{ - selection: config.ProviderSelection{ProviderID: "openai", ModelID: "gpt-5.4"}, + selection: configstate.Selection{ProviderID: "openai", ModelID: "gpt-5.4"}, models: []providertypes.ModelDescriptor{{ID: "gpt-5.4", Name: "GPT-5.4"}}, } - msg := SelectProviderCmd(svc, "openai", func(sel config.ProviderSelection, err error) tea.Msg { return sel })() - if sel, ok := msg.(config.ProviderSelection); !ok || sel.ProviderID != "openai" { + msg := SelectProviderCmd(svc, "openai", func(sel configstate.Selection, err error) tea.Msg { return sel })() + if sel, ok := msg.(configstate.Selection); !ok || sel.ProviderID != "openai" { t.Fatalf("expected provider selection msg, got %T %#v", msg, msg) } - msg = SelectModelCmd(svc, "gpt-5.4", func(sel config.ProviderSelection, err error) tea.Msg { return sel })() - if sel, ok := msg.(config.ProviderSelection); !ok || sel.ModelID != "gpt-5.4" { + msg = SelectModelCmd(svc, "gpt-5.4", func(sel configstate.Selection, err error) tea.Msg { return sel })() + if sel, ok := msg.(configstate.Selection); !ok || sel.ModelID != "gpt-5.4" { t.Fatalf("expected model selection msg, got %T %#v", msg, msg) } From 975522a256b9393da30d4e5ccfba0fc322531093 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sun, 12 Apr 2026 13:15:20 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(config):=E4=BF=AE=E5=A4=8D=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/config_test.go | 16 +++++++++------ internal/tui/core/app/update_test.go | 30 +++++++++++----------------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2f679e0e..7b0f3e58 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -402,20 +402,18 @@ func TestConfigValidateFailures(t *testing.T) { expectErr: "workdir must be absolute", }, { - name: "non-existent workdir", + name: "non-existent workdir is accepted by ValidateSnapshot (no filesystem check)", config: func() *Config { cfg := testDefaultConfig() - cfg.ApplyDefaultsFrom(*testDefaultConfig()) cfg.Workdir = filepath.Join(t.TempDir(), "does-not-exist") return cfg }(), - expectErr: "workdir does not exist", + expectErr: "", // ValidateSnapshot 只校验路径格式,不做文件系统检查 }, { - name: "workdir is a file", + name: "workdir pointing to a file is accepted by ValidateSnapshot (no filesystem check)", config: func() *Config { cfg := testDefaultConfig() - cfg.ApplyDefaultsFrom(*testDefaultConfig()) filePath := filepath.Join(t.TempDir(), "a-file.txt") if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { t.Fatalf("setup: %v", err) @@ -423,7 +421,7 @@ func TestConfigValidateFailures(t *testing.T) { cfg.Workdir = filePath return cfg }(), - expectErr: "workdir is not a directory", + expectErr: "", // ValidateSnapshot 不验证 workdir 是否为目录 }, { name: "selected provider model empty", @@ -515,6 +513,12 @@ func TestConfigValidateFailures(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() err := tt.config.ValidateSnapshot() + if tt.expectErr == "" { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + return + } if err == nil || !strings.Contains(err.Error(), tt.expectErr) { t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) } diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index e2f3aea9..5f43b21a 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -1221,29 +1221,23 @@ func TestRunSlashCommandSelectionWorkspaceAndLocal(t *testing.T) { app.state.ActiveSessionID = "" app.state.CurrentWorkdir = t.TempDir() - workspaceCmd := app.runSlashCommandSelection("/cwd") - if workspaceCmd == nil { - t.Fatalf("expected workspace slash cmd") - } - workspaceMsg := workspaceCmd() - workspaceResult, ok := workspaceMsg.(sessionWorkdirResultMsg) - if !ok { - t.Fatalf("expected sessionWorkdirResultMsg, got %T", workspaceMsg) - } - if workspaceResult.Err != nil { - t.Fatalf("expected no workspace error, got %v", workspaceResult.Err) + // /cwd 不是 handleImmediateSlashCommand 处理的命令,也不是 switch 中的已知命令, + // 所以走 default 分支返回 runLocalCommand -> localCommandResultMsg + localCmd := app.runSlashCommandSelection("/cwd") + if localCmd == nil { + t.Fatalf("expected local slash cmd for /cwd") } - localCmd := app.runSlashCommandSelection(slashCommandStatus) - if localCmd == nil { - t.Fatalf("expected local slash cmd") + statusCmd := app.runSlashCommandSelection(slashCommandStatus) + if statusCmd == nil { + t.Fatalf("expected local slash cmd for status") } - localMsg := localCmd() - localResult, ok := localMsg.(localCommandResultMsg) + statusMsg := statusCmd() + statusResult, ok := statusMsg.(localCommandResultMsg) if !ok { - t.Fatalf("expected localCommandResultMsg, got %T", localMsg) + t.Fatalf("expected localCommandResultMsg, got %T", statusMsg) } - if !strings.Contains(localResult.Notice, "Status:") { + if !strings.Contains(statusResult.Notice, "Status:") { t.Fatalf("expected status output in local command result") } } From 4f41dedb329635ce16887fd4ec654ca01510a1ab Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sun, 12 Apr 2026 13:20:28 +0800 Subject: [PATCH 3/5] =?UTF-8?q?pref(config):=E4=BC=98=E5=8C=96=E6=97=A7?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/state/service.go | 1 + internal/config/state/service_test.go | 46 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/internal/config/state/service.go b/internal/config/state/service.go index 5b586b91..349d47d9 100644 --- a/internal/config/state/service.go +++ b/internal/config/state/service.go @@ -228,6 +228,7 @@ func (s *Service) EnsureSelection(ctx context.Context) (Selection, error) { if err != nil { return Selection{}, err } + cfgSnapshot = s.manager.Get() } if err := ensureSupportedProvider(s.supporters, selected); err != nil { return Selection{}, err diff --git a/internal/config/state/service_test.go b/internal/config/state/service_test.go index e19c0a33..6d100aa2 100644 --- a/internal/config/state/service_test.go +++ b/internal/config/state/service_test.go @@ -552,6 +552,52 @@ func TestSelectionServiceEnsureSelectionKeepsEmptyCustomModelWhenSynchronousDisc } } +func TestSelectionServiceEnsureSelectionReturnsBootstrappedSelectionWhenCustomDiscoveryFails(t *testing.T) { + t.Parallel() + + defaults := testDefaultConfig() + defaults.Providers = []configpkg.ProviderConfig{ + { + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "COMPANY_GATEWAY_API_KEY", + Source: ProviderSourceCustom, + }, + } + defaults.SelectedProvider = "" + defaults.CurrentModel = "" + + tracker := &catalogMethodCalls{} + manager := newSelectionTestManager(t, defaults) + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ + listErr: errors.New("discover failed"), + tracker: tracker, + }) + + selection, err := service.EnsureSelection(context.Background()) + if err != nil { + t.Fatalf("EnsureSelection() should preserve the bootstrapped selection when discovery fails, got %v", err) + } + if selection.ProviderID != "company-gateway" || selection.ModelID != "" { + t.Fatalf("expected bootstrapped custom selection to be returned, got %+v", selection) + } + if tracker.listCalls != 1 || tracker.snapshotCalls != 1 { + t.Fatalf("expected custom ensure to attempt snapshot and one sync discovery, got %+v", *tracker) + } + + reloaded, err := manager.Reload(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + if reloaded.SelectedProvider != "company-gateway" { + t.Fatalf("expected selected provider to persist as company-gateway, got %q", reloaded.SelectedProvider) + } + if reloaded.CurrentModel != "" { + t.Fatalf("expected current model to remain empty after failed discovery, got %q", reloaded.CurrentModel) + } +} + func TestSelectionServiceSelectCustomProviderDoesNotPersistWhenDiscoveryFails(t *testing.T) { t.Parallel() From b2ac36767d4da9f2611777a793e1daf08815d467 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sun, 12 Apr 2026 13:49:04 +0800 Subject: [PATCH 4/5] =?UTF-8?q?pref(config):=E4=BC=98=E5=8C=96=E6=97=A7?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E6=A8=A1=E5=9E=8B=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E5=86=99=E5=85=A5=E6=96=B0=E4=BE=9B=E5=BA=94=E5=95=86=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/state/model.go | 30 +++++++ internal/config/state/service.go | 47 ++++++++++- internal/config/state/service_test.go | 115 ++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 2 deletions(-) diff --git a/internal/config/state/model.go b/internal/config/state/model.go index 1992eaf3..4aec5033 100644 --- a/internal/config/state/model.go +++ b/internal/config/state/model.go @@ -30,6 +30,7 @@ var ( ErrModelNotFound = errors.New("model not found") ErrNoModelsAvailable = errors.New("provider has no available models") ErrDriverUnsupported = errors.New("provider driver not supported by current runtime") + errSelectionDrifted = errors.New("selection drifted during update") ) // DriverSupporter 用于检查给定 driver 是否受当前运行时支持。 @@ -150,3 +151,32 @@ func ensureSupportedProvider(supporters DriverSupporter, cfg config.ProviderConf ErrDriverUnsupported, ) } + +// sameProviderIdentity 比较两个 provider 配置是否仍然指向同一个底层连接身份。 +func sameProviderIdentity(left config.ProviderConfig, right config.ProviderConfig) (bool, error) { + leftIdentity, err := left.Identity() + if err != nil { + return false, err + } + rightIdentity, err := right.Identity() + if err != nil { + return false, err + } + return leftIdentity == rightIdentity, nil +} + +// sameSelectionSnapshot 判断最新配置是否仍与旧快照指向同一 provider 且保持相同的当前模型。 +func sameSelectionSnapshot(latest config.Config, snapshot config.Config, expected config.ProviderConfig) (bool, error) { + latestSelected, err := selectedProviderConfig(latest) + if err != nil { + return false, nil + } + sameIdentity, err := sameProviderIdentity(latestSelected, expected) + if err != nil { + return false, err + } + if !sameIdentity { + return false, nil + } + return strings.TrimSpace(latest.CurrentModel) == strings.TrimSpace(snapshot.CurrentModel), nil +} diff --git a/internal/config/state/service.go b/internal/config/state/service.go index 349d47d9..4167f387 100644 --- a/internal/config/state/service.go +++ b/internal/config/state/service.go @@ -214,6 +214,24 @@ func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (Selectio // EnsureSelection 确保当前 provider 和 model 仍然有效,必要时自动修正。 func (s *Service) EnsureSelection(ctx context.Context) (Selection, error) { + const maxEnsureSelectionAttempts = 2 + + for attempt := 0; attempt < maxEnsureSelectionAttempts; attempt++ { + selection, err := s.ensureSelectionOnce(ctx) + if err == nil { + return selection, nil + } + if errors.Is(err, errSelectionDrifted) && attempt+1 < maxEnsureSelectionAttempts { + continue + } + return Selection{}, err + } + + return Selection{}, errSelectionDrifted +} + +// ensureSelectionOnce 基于一次稳定快照校正当前 provider/model 选择,并在写入前校验 provider 未漂移。 +func (s *Service) ensureSelectionOnce(ctx context.Context) (Selection, error) { if err := s.validate(); err != nil { return Selection{}, err } @@ -251,7 +269,15 @@ func (s *Service) EnsureSelection(ctx context.Context) (Selection, error) { } } if len(models) == 0 { - return selectionFromConfig(cfgSnapshot), nil + latestSnapshot := s.manager.Get() + sameSelection, sameErr := sameSelectionSnapshot(latestSnapshot, cfgSnapshot, selected) + if sameErr != nil { + return Selection{}, sameErr + } + if !sameSelection { + return Selection{}, errSelectionDrifted + } + return selectionFromConfig(latestSnapshot), nil } } else { models = providertypes.DescriptorsFromIDs([]string{strings.TrimSpace(selected.Model)}) @@ -262,7 +288,15 @@ func (s *Service) EnsureSelection(ctx context.Context) (Selection, error) { } _, modelChanged := resolveCurrentModel(cfgSnapshot.CurrentModel, models, selected.Model) if !modelChanged && strings.TrimSpace(cfgSnapshot.SelectedProvider) != "" { - return selectionFromConfig(cfgSnapshot), nil + latestSnapshot := s.manager.Get() + sameSelection, sameErr := sameSelectionSnapshot(latestSnapshot, cfgSnapshot, selected) + if sameErr != nil { + return Selection{}, sameErr + } + if !sameSelection { + return Selection{}, errSelectionDrifted + } + return selectionFromConfig(latestSnapshot), nil } var selection Selection @@ -271,7 +305,16 @@ func (s *Service) EnsureSelection(ctx context.Context) (Selection, error) { if err != nil { currentSelected = selected cfg.SelectedProvider = selected.Name + } else { + sameIdentity, identityErr := sameProviderIdentity(currentSelected, selected) + if identityErr != nil { + return identityErr + } + if !sameIdentity { + return errSelectionDrifted + } } + cfg.CurrentModel, _ = resolveCurrentModel(cfg.CurrentModel, models, currentSelected.Model) selection = selectionFromConfig(*cfg) return nil diff --git a/internal/config/state/service_test.go b/internal/config/state/service_test.go index 6d100aa2..9f4425ef 100644 --- a/internal/config/state/service_test.go +++ b/internal/config/state/service_test.go @@ -598,6 +598,72 @@ func TestSelectionServiceEnsureSelectionReturnsBootstrappedSelectionWhenCustomDi } } +func TestSelectionServiceEnsureSelectionRetriesWhenProviderDriftsDuringUpdate(t *testing.T) { + t.Parallel() + + defaults := testDefaultConfig() + defaults.SelectedProvider = OpenAIName + defaults.CurrentModel = "invalid-openai-model" + + manager := newSelectionTestManager(t, defaults) + service := NewService(manager, newDriverSupporterStub(), &driftingSnapshotCatalog{ + t: t, + manager: manager, + }) + + selection, err := service.EnsureSelection(context.Background()) + if err != nil { + t.Fatalf("EnsureSelection() error = %v", err) + } + if selection.ProviderID != QiniuName || selection.ModelID != QiniuDefaultModel { + t.Fatalf("expected retried selection to use drifted provider snapshot, got %+v", selection) + } + + reloaded, err := manager.Reload(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + if reloaded.SelectedProvider != QiniuName { + t.Fatalf("expected selected provider to persist as %q, got %q", QiniuName, reloaded.SelectedProvider) + } + if reloaded.CurrentModel != QiniuDefaultModel { + t.Fatalf("expected current model to be repaired to %q, got %q", QiniuDefaultModel, reloaded.CurrentModel) + } +} + +func TestSelectionServiceEnsureSelectionRetriesWhenProviderDriftsBeforeEarlyReturn(t *testing.T) { + t.Parallel() + + defaults := testDefaultConfig() + defaults.SelectedProvider = OpenAIName + defaults.CurrentModel = OpenAIDefaultModel + + manager := newSelectionTestManager(t, defaults) + service := NewService(manager, newDriverSupporterStub(), &driftingSnapshotCatalog{ + t: t, + manager: manager, + }) + + selection, err := service.EnsureSelection(context.Background()) + if err != nil { + t.Fatalf("EnsureSelection() error = %v", err) + } + if selection.ProviderID != QiniuName || selection.ModelID != QiniuDefaultModel { + t.Fatalf("expected drifted provider selection after retry, got %+v", selection) + } + + reloaded, err := manager.Reload(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + if reloaded.SelectedProvider != QiniuName { + t.Fatalf("expected selected provider to persist as %q, got %q", QiniuName, reloaded.SelectedProvider) + } + if reloaded.CurrentModel != QiniuDefaultModel { + t.Fatalf("expected current model to be repaired to %q, got %q", QiniuDefaultModel, reloaded.CurrentModel) + } +} + func TestSelectionServiceSelectCustomProviderDoesNotPersistWhenDiscoveryFails(t *testing.T) { t.Parallel() @@ -919,6 +985,55 @@ func (s errorCatalogStub) ListProviderModelsCached(_ context.Context, _ provider return nil, s.err } +type driftingSnapshotCatalog struct { + t *testing.T + manager *configpkg.Manager + switched bool +} + +func (c *driftingSnapshotCatalog) ListProviderModels(_ context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return c.modelsFor(input), nil +} + +func (c *driftingSnapshotCatalog) ListProviderModelsSnapshot(ctx context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + if !c.switched && strings.EqualFold(input.Identity.Key(), mustCatalogIdentity(c.t, configpkg.OpenAIProvider()).Key()) { + c.switched = true + if err := c.manager.Update(ctx, func(cfg *configpkg.Config) error { + cfg.SelectedProvider = QiniuName + cfg.CurrentModel = "stale-openai-model" + return nil + }); err != nil { + c.t.Fatalf("seed provider drift: %v", err) + } + } + return c.modelsFor(input), nil +} + +func (c *driftingSnapshotCatalog) ListProviderModelsCached(_ context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return c.modelsFor(input), nil +} + +func (c *driftingSnapshotCatalog) modelsFor(input provider.CatalogInput) []providertypes.ModelDescriptor { + switch input.Identity.Key() { + case mustCatalogIdentity(c.t, configpkg.OpenAIProvider()).Key(): + return []providertypes.ModelDescriptor{{ID: OpenAIDefaultModel, Name: OpenAIDefaultModel}} + case mustCatalogIdentity(c.t, configpkg.QiniuProvider()).Key(): + return []providertypes.ModelDescriptor{{ID: QiniuDefaultModel, Name: QiniuDefaultModel}} + default: + return nil + } +} + +func mustCatalogIdentity(t *testing.T, cfg configpkg.ProviderConfig) provider.ProviderIdentity { + t.Helper() + + identity, err := cfg.Identity() + if err != nil { + t.Fatalf("Identity() error = %v", err) + } + return identity +} + // defaultModelsForInput 为给定 catalog 输入返回默认模型及其变体,便于选择逻辑测试复用。 func defaultModelsForInput(input provider.CatalogInput) []providertypes.ModelDescriptor { defaults := providertypes.MergeModelDescriptors(input.DefaultModels) From 4dfb79684e222419c1b0b4a56579b0cce2f0301f Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 12 Apr 2026 06:20:40 +0000 Subject: [PATCH 5/5] test(config): improve selection state branch coverage Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/config/state/model_test.go | 107 ++++++++++ internal/config/state/service_test.go | 277 ++++++++++++++++++++++++++ 2 files changed, 384 insertions(+) diff --git a/internal/config/state/model_test.go b/internal/config/state/model_test.go index 22c328c8..77bb88e8 100644 --- a/internal/config/state/model_test.go +++ b/internal/config/state/model_test.go @@ -127,3 +127,110 @@ func TestCatalogInputFromProviderPropagatesIdentityErrors(t *testing.T) { t.Fatalf("expected base_url validation error, got %v", err) } } + +func TestCatalogInputFromProviderResolveDiscoveryConfigPropagatesResolveError(t *testing.T) { + t.Parallel() + + input, err := catalogInputFromProvider(configpkg.ProviderConfig{ + Name: "company-gateway", + Driver: "openaicompat", + BaseURL: "https://api.example.com/v1", + Model: "server-default", + APIKeyEnv: "MISSING_PROVIDER_API_KEY", + Source: configpkg.ProviderSourceBuiltin, + }) + if err != nil { + t.Fatalf("catalogInputFromProvider() error = %v", err) + } + + _, err = input.ResolveDiscoveryConfig() + if err == nil || !strings.Contains(err.Error(), "environment variable MISSING_PROVIDER_API_KEY is empty") { + t.Fatalf("expected resolve api key error, got %v", err) + } +} + +func TestSameProviderIdentityPropagatesIdentityErrors(t *testing.T) { + t.Parallel() + + _, err := sameProviderIdentity(configpkg.ProviderConfig{ + Name: "broken-left", + Driver: "openaicompat", + BaseURL: " ", + APIKeyEnv: "LEFT_KEY", + }, configpkg.ProviderConfig{ + Name: "right", + Driver: "openaicompat", + BaseURL: "https://api.example.com/v1", + APIKeyEnv: "RIGHT_KEY", + }) + if err == nil || !strings.Contains(err.Error(), "base_url is empty") { + t.Fatalf("expected left identity error, got %v", err) + } + + _, err = sameProviderIdentity(configpkg.ProviderConfig{ + Name: "left", + Driver: "openaicompat", + BaseURL: "https://api.example.com/v1", + APIKeyEnv: "LEFT_KEY", + }, configpkg.ProviderConfig{ + Name: "broken-right", + Driver: "openaicompat", + BaseURL: " ", + APIKeyEnv: "RIGHT_KEY", + }) + if err == nil || !strings.Contains(err.Error(), "base_url is empty") { + t.Fatalf("expected right identity error, got %v", err) + } +} + +func TestSameSelectionSnapshotReturnsFalseWhenLatestSelectionMissing(t *testing.T) { + t.Parallel() + + same, err := sameSelectionSnapshot( + configpkg.Config{ + SelectedProvider: "", + Providers: []configpkg.ProviderConfig{ + configpkg.OpenAIProvider(), + }, + }, + configpkg.Config{CurrentModel: configpkg.OpenAIDefaultModel}, + configpkg.OpenAIProvider(), + ) + if err != nil { + t.Fatalf("sameSelectionSnapshot() unexpected error = %v", err) + } + if same { + t.Fatalf("expected latest snapshot without selection to be treated as different") + } +} + +func TestSameSelectionSnapshotPropagatesIdentityErrors(t *testing.T) { + t.Parallel() + + latest := configpkg.Config{ + SelectedProvider: "openai", + Providers: []configpkg.ProviderConfig{ + { + Name: "openai", + Driver: "openaicompat", + BaseURL: " ", + Model: configpkg.OpenAIDefaultModel, + APIKeyEnv: "OPENAI_API_KEY", + Source: configpkg.ProviderSourceBuiltin, + }, + }, + } + + _, err := sameSelectionSnapshot( + latest, + configpkg.Config{ + SelectedProvider: "openai", + CurrentModel: configpkg.OpenAIDefaultModel, + Providers: []configpkg.ProviderConfig{configpkg.OpenAIProvider()}, + }, + configpkg.OpenAIProvider(), + ) + if err == nil || !strings.Contains(err.Error(), "base_url is empty") { + t.Fatalf("expected identity error from latest selected provider, got %v", err) + } +} diff --git a/internal/config/state/service_test.go b/internal/config/state/service_test.go index 9f4425ef..bb678ad3 100644 --- a/internal/config/state/service_test.go +++ b/internal/config/state/service_test.go @@ -836,6 +836,222 @@ func TestSelectionServiceSetCurrentModelEmptyModelID(t *testing.T) { } } +func TestSelectionServiceNilReceiverValidationAcrossMethods(t *testing.T) { + t.Parallel() + + var service *Service + + tests := []struct { + name string + fn func() error + }{ + { + name: "SelectProvider", + fn: func() error { + _, err := service.SelectProvider(context.Background(), OpenAIName) + return err + }, + }, + { + name: "ListModels", + fn: func() error { + _, err := service.ListModels(context.Background()) + return err + }, + }, + { + name: "ListModelsSnapshot", + fn: func() error { + _, err := service.ListModelsSnapshot(context.Background()) + return err + }, + }, + { + name: "SetCurrentModel", + fn: func() error { + _, err := service.SetCurrentModel(context.Background(), OpenAIDefaultModel) + return err + }, + }, + { + name: "EnsureSelection", + fn: func() error { + _, err := service.EnsureSelection(context.Background()) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.fn() + if err == nil || err.Error() != "selection: service is nil" { + t.Fatalf("expected nil-service validation error, got %v", err) + } + }) + } +} + +func TestSelectionServiceListProviderOptionsHonorsContextCanceledMidIteration(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, testDefaultConfig()) + ctx, cancel := context.WithCancel(context.Background()) + service := NewService(manager, newDriverSupporterStub(), &cancelAfterFirstCachedCatalog{cancel: cancel}) + + _, err := service.ListProviderOptions(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled when canceled mid-iteration, got %v", err) + } +} + +func TestSelectionServiceSelectProviderNotFound(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, newDriverSupporterStub(), newCatalogStub()) + + _, err := service.SelectProvider(context.Background(), "missing-provider") + if !errors.Is(err, ErrProviderNotFound) { + t.Fatalf("expected ErrProviderNotFound, got %v", err) + } +} + +func TestSelectionServiceSelectProviderFailsWhenDriverBecomesUnsupportedDuringUpdate(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, &flappingDriverSupporter{}, newCatalogStub()) + + _, err := service.SelectProvider(context.Background(), OpenAIName) + if !errors.Is(err, ErrDriverUnsupported) { + t.Fatalf("expected ErrDriverUnsupported during update closure, got %v", err) + } +} + +func TestSelectionServiceSelectProviderFailsWhenTargetProviderRemovedBeforeUpdate(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, newDriverSupporterStub(), &mutatingCatalog{ + snapshotModels: []providertypes.ModelDescriptor{ + {ID: QiniuDefaultModel, Name: QiniuDefaultModel}, + }, + onSnapshot: func(ctx context.Context) error { + return manager.Update(ctx, func(cfg *configpkg.Config) error { + kept := make([]configpkg.ProviderConfig, 0, len(cfg.Providers)) + for _, providerCfg := range cfg.Providers { + if providerCfg.Name == QiniuName { + continue + } + kept = append(kept, providerCfg) + } + cfg.Providers = kept + if cfg.SelectedProvider == QiniuName { + cfg.SelectedProvider = OpenAIName + cfg.CurrentModel = OpenAIDefaultModel + } + return nil + }) + }, + }) + + _, err := service.SelectProvider(context.Background(), QiniuName) + if !errors.Is(err, ErrProviderNotFound) { + t.Fatalf("expected ErrProviderNotFound after provider removal, got %v", err) + } +} + +func TestSelectionServiceListModelsFailsWithoutSelection(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, testDefaultConfig()) + if err := manager.Update(context.Background(), func(cfg *configpkg.Config) error { + cfg.SelectedProvider = "" + cfg.CurrentModel = "" + return nil + }); err != nil { + t.Fatalf("clear selection: %v", err) + } + + service := NewService(manager, newDriverSupporterStub(), newCatalogStub()) + _, err := service.ListModels(context.Background()) + if !errors.Is(err, ErrProviderNotFound) { + t.Fatalf("expected ErrProviderNotFound for empty selection, got %v", err) + } +} + +func TestSelectionServiceListModelsSnapshotFailsWithoutSelection(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, testDefaultConfig()) + if err := manager.Update(context.Background(), func(cfg *configpkg.Config) error { + cfg.SelectedProvider = "" + cfg.CurrentModel = "" + return nil + }); err != nil { + t.Fatalf("clear selection: %v", err) + } + + service := NewService(manager, newDriverSupporterStub(), newCatalogStub()) + _, err := service.ListModelsSnapshot(context.Background()) + if !errors.Is(err, ErrProviderNotFound) { + t.Fatalf("expected ErrProviderNotFound for empty selection, got %v", err) + } +} + +func TestSelectionServiceSetCurrentModelPropagatesCatalogError(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, testDefaultConfig()) + backendErr := errors.New("model discovery failed") + service := NewService(manager, newDriverSupporterStub(), errorCatalogStub{err: backendErr}) + + _, err := service.SetCurrentModel(context.Background(), OpenAIDefaultModel) + if !errors.Is(err, backendErr) { + t.Fatalf("expected catalog error propagation, got %v", err) + } +} + +func TestSelectionServiceSetCurrentModelFailsWhenSelectionDriftsBeforeUpdate(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, newDriverSupporterStub(), &mutatingCatalog{ + listModels: []providertypes.ModelDescriptor{ + {ID: OpenAIDefaultModel, Name: OpenAIDefaultModel}, + }, + onList: func(ctx context.Context) error { + return manager.Update(ctx, func(cfg *configpkg.Config) error { + cfg.SelectedProvider = "" + cfg.CurrentModel = "" + return nil + }) + }, + }) + + _, err := service.SetCurrentModel(context.Background(), OpenAIDefaultModel) + if !errors.Is(err, ErrProviderNotFound) { + t.Fatalf("expected ErrProviderNotFound when selection drifts, got %v", err) + } +} + +func TestSelectionServiceBootstrapInitialSelectionPropagatesUpdateError(t *testing.T) { + t.Parallel() + + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, newDriverSupporterStub(), newCatalogStub()) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := service.bootstrapInitialSelection(ctx, manager.Get()) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled from bootstrap update, got %v", err) + } +} + func TestResolveCurrentModelHelper(t *testing.T) { t.Parallel() @@ -909,6 +1125,15 @@ func (s *selectiveDriverSupporter) Supports(driverType string) bool { return s.supported[provider.NormalizeKey(driverType)] } +type flappingDriverSupporter struct { + calls int +} + +func (s *flappingDriverSupporter) Supports(_ string) bool { + s.calls++ + return s.calls == 1 +} + type catalogStub struct{} func (catalogStub) ListProviderModels(_ context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { @@ -969,6 +1194,58 @@ type catalogMethodCalls struct { cachedCalls int } +type cancelAfterFirstCachedCatalog struct { + cancel context.CancelFunc + calls int +} + +func (c *cancelAfterFirstCachedCatalog) ListProviderModels(_ context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return defaultModelsForInput(input), nil +} + +func (c *cancelAfterFirstCachedCatalog) ListProviderModelsSnapshot(_ context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return defaultModelsForInput(input), nil +} + +func (c *cancelAfterFirstCachedCatalog) ListProviderModelsCached(_ context.Context, input provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + c.calls++ + if c.calls == 1 && c.cancel != nil { + c.cancel() + } + return defaultModelsForInput(input), nil +} + +type mutatingCatalog struct { + listModels []providertypes.ModelDescriptor + snapshotModels []providertypes.ModelDescriptor + onList func(context.Context) error + onSnapshot func(context.Context) error +} + +func (m *mutatingCatalog) ListProviderModels(ctx context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + if m.onList != nil { + if err := m.onList(ctx); err != nil { + return nil, err + } + m.onList = nil + } + return providertypes.MergeModelDescriptors(m.listModels), nil +} + +func (m *mutatingCatalog) ListProviderModelsSnapshot(ctx context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + if m.onSnapshot != nil { + if err := m.onSnapshot(ctx); err != nil { + return nil, err + } + m.onSnapshot = nil + } + return providertypes.MergeModelDescriptors(m.snapshotModels), nil +} + +func (m *mutatingCatalog) ListProviderModelsCached(_ context.Context, _ provider.CatalogInput) ([]providertypes.ModelDescriptor, error) { + return nil, nil +} + type errorCatalogStub struct { err error }