diff --git a/README.md b/README.md index 4398146a..41317fa7 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,129 @@ # NeoCode -NeoCode 是一个基于 Go 和 Bubble Tea 的本地编码 Agent。它在终端中运行 ReAct 闭环,能够对话、调用工具、持久化会话,并以流式方式展示模型输出。 +> 基于 Go + Bubble Tea 的本地 Coding Agent -## 当前 Provider 策略 +NeoCode 是一个在终端中运行的 AI 编码助手,采用 ReAct(Reason-Act-Observe)循环模式,能够自主推理、调用工具并完成任务。 -- 内建 provider 定义随代码版本发布。 -- `config.yaml` 不再持久化完整 `providers` 列表。 -- `config.yaml` 只保存当前选择状态和通用运行配置。 -- 运行时的 `providers` 完全来自代码内建定义。 -- API Key 只从环境变量读取,不写入 YAML。 -- 当前内建 provider 包括 `openai` 和 `gemini`。 -- `gemini` 复用 OpenAI-compatible driver,请求地址指向 Gemini 的兼容接口。 -- provider 实例自己定义 `base_url`、默认模型、可选模型列表和 `api_key_env`。 -- `base_url` 不在 TUI 中展示给用户。 -- driver 只负责协议构造与响应解析,不决定 `models`、`base_url` 或 `api_key_env`。 +## 核心特性 -这意味着: +- **流式输出** — 实时展示模型思考过程 +- **工具系统** — 文件操作、代码执行、搜索等内置工具 +- **多 Provider 支持** — OpenAI、Gemini 等主流模型,易于扩展 +- **终端原生体验** — 基于 Bubble Tea 的现代化 TUI -- 新用户启动后会自动拿到当前版本最新的内建 provider。 -- 未来代码新增 provider 时,新用户不需要修改 YAML。 -- 老配置文件中的 `providers` / `provider_overrides` 会在加载时被清理为新的最小状态格式。 +## 快速开始 -## 配置文件 +### 环境要求 -默认路径: -[`~/.neocode/config.yaml`](~/.neocode/config.yaml) +- Go 1.21+ +- API Key(OpenAI 或 Google Gemini) -当前落盘结构示例: +### 安装与运行 -```yaml -selected_provider: openai -current_model: gpt-5.4 -workdir: . -shell: powershell -max_loops: 8 -tool_timeout_sec: 20 +```bash +# 克隆仓库 +git clone https://github.com/yourusername/neocode.git +cd neocode + +# 设置 API Key +export OPENAI_API_KEY=your_key_here + +# 运行 +go run ./cmd/neocode ``` -其中: +### 基本使用 + +在 TUI 中输入自然语言指令,例如: + +``` +帮我看看当前项目的目录结构 +创建一个 HTTP 服务器,监听 8080 端口 +分析 runtime.go 的主要逻辑 +``` -- `selected_provider` 和 `current_model` 是用户当前选择。 -- provider 的 `base_url`、`models`、`api_key_env` 和 `driver` 都由开发者在代码中预设。 -- `openai` 默认读取 `OPENAI_API_KEY`,`gemini` 默认读取 `GEMINI_API_KEY`。 -- 完整 provider 列表不落盘,用户不需要在 YAML 中维护供应商元数据。 +使用 slash 命令快速切换配置: -## Slash Commands +- `/provider` — 切换模型提供商 +- `/model` — 切换模型 -- `/provider`:打开 provider 选择器。 -- `/model`:打开当前 provider 的模型选择器。 +## 架构概览 -## 运行 +``` +┌─────────────────────────────────────────┐ +│ TUI (Bubble Tea) │ +└────────────────┬────────────────────────┘ + │ Events +┌────────────────▼────────────────────────┐ +│ Runtime (ReAct Loop) │ +└────────┬───────────────────┬────────────┘ + │ │ + ┌────▼─────┐ ┌────▼──────┐ + │ Provider │ │ Tools │ + │ (LLM) │ │ Registry │ + └──────────┘ └───────────┘ +``` -```bash -go run ./cmd/neocode +核心模块职责: + +- **`internal/config`** — 配置管理、环境变量、YAML 加载 +- **`internal/provider`** — LLM 提供商抽象,抹平厂商差异 +- **`internal/runtime`** — ReAct 主循环、事件流、会话管理 +- **`internal/tools`** — 工具注册表与具体工具实现 +- **`internal/tui`** — 终端 UI、交互体验、事件桥接 +- **`internal/app`** — 应用装配与依赖注入 + +## 目录结构 + +```text +. +├── cmd/neocode # CLI 入口 +├── docs # 架构与设计文档 +│ ├── guides # 使用指南 +│ └── *.md # 设计文档 +├── internal +│ ├── app # 应用装配 +│ ├── config # 配置管理 +│ ├── provider # Provider 抽象与实现 +│ ├── runtime # ReAct 循环与事件流 +│ ├── tools # 工具系统 +│ └── tui # 终端 UI +└── README.md ``` +## 文档 + +- **[配置指南](docs/guides/configuration.md)** — Provider 策略、配置文件、环境变量 +- **[扩展 Provider](docs/guides/adding-providers.md)** — 如何添加新的模型提供商 +- **[架构设计](docs/neocode-coding-agent-mvp-architecture.md)** — 整体架构与设计理念 +- **[事件流](docs/runtime-provider-event-flow.md)** — Runtime 与 Provider 的事件交互 + ## 开发 ```bash +# 格式化代码 gofmt -w ./cmd ./internal + +# 运行测试 go test ./... + +# 编译 +go build ./... ``` + +## 当前状态 + +NeoCode 正处于 MVP 阶段,核心闭环已可用: + +✅ 用户输入 → Agent 推理 → 工具调用 → 结果返回 → UI 展示 + +正在持续迭代中,重点关注: + +- 📚 文档完善 +- 🧪 测试覆盖率 +- 🛠️ 工具能力扩展 +- 🔧 稳定性与性能 + +## License + +MIT diff --git a/docs/guides/adding-providers.md b/docs/guides/adding-providers.md new file mode 100644 index 00000000..bd9d0a88 --- /dev/null +++ b/docs/guides/adding-providers.md @@ -0,0 +1,185 @@ +# 扩展 Provider + +本文档说明如何为 NeoCode 添加新的 Provider。 + +## 架构概览 + +NeoCode 的 provider 分为两层: + +- **配置层**(`ProviderConfig`):提供 `base_url`、模型列表、API Key 环境变量等元数据 +- **驱动层**(`DriverDefinition`):负责实际的 API 协议差异 + +一个驱动可被多个 provider 复用(如 `gemini` 和 `openll` 都复用 `openai` 驱动)。 + +## 方式一:复用已有驱动(推荐) + +适用于 OpenAI 兼容接口。只需添加配置文件,无需编写新的驱动代码。 + +### 示例:添加 DeepSeek + +**1. 创建 `internal/provider/deepseek/deepseek.go`:** + +```go +package deepseek + +import ( + "neo-code/internal/config" +) + +const ( + Name = "deepseek" + DriverName = "openai" // 复用 openai 驱动 + DefaultBaseURL = "https://api.deepseek.com/v1" + DefaultModel = "deepseek-chat" + DefaultAPIKeyEnv = "DEEPSEEK_API_KEY" +) + +var builtinModels = []string{ + DefaultModel, + "deepseek-coder", +} + +// BuiltinConfig 返回该 provider 的内建配置。 +func BuiltinConfig() config.ProviderConfig { + return config.ProviderConfig{ + Name: Name, + Driver: DriverName, + BaseURL: DefaultBaseURL, + Model: DefaultModel, + Models: append([]string(nil), builtinModels...), + APIKeyEnv: DefaultAPIKeyEnv, + } +} +``` + +**2. 在 `internal/provider/builtin/builtin.go` 中添加该 provider:** + +```go +import "neo-code/internal/provider/deepseek" + +func DefaultConfig() *config.Config { + cfg := config.Default() + defaultProvider := openai.BuiltinConfig() + cfg.Providers = []config.ProviderConfig{ + defaultProvider, + gemini.BuiltinConfig(), + openll.BuiltinConfig(), + deepseek.BuiltinConfig(), // 新增 + } + // ... +} +``` + +## 方式二:实现新驱动 + +适用于协议不兼容的厂商(如 Anthropic、Google 原生 API)。 + +### 示例:添加 Anthropic + +**1. 在 `internal/provider/anthropic/anthropic.go` 中实现核心类型:** + +```go +package anthropic + +import ( + "context" + "neo-code/internal/config" + domain "neo-code/internal/provider" +) + +const ( + Name = "anthropic" + DriverName = "anthropic" + DefaultBaseURL = "https://api.anthropic.com/v1" + DefaultModel = "claude-sonnet-4-20250514" + DefaultAPIKeyEnv = "ANTHROPIC_API_KEY" +) + +type Provider struct { + cfg config.ResolvedProviderConfig + client *http.Client +} + +// New 构造函数,接收已解析的配置。 +func New(cfg config.ResolvedProviderConfig) (*Provider, error) { + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("anthropic provider: %w", err) + } + // ... + return &Provider{cfg: cfg, client: &http.Client{}}, nil +} + +// Chat 实现 domain.Provider 接口。 +// 必须支持流式输出(通过 events channel)和 tool calls。 +func (p *Provider) Chat(ctx context.Context, req domain.ChatRequest, events chan<- domain.StreamEvent) (domain.ChatResponse, error) { + // 1. 将 domain.ChatRequest 转换为厂商特定的请求格式 + // 2. 调用厂商 API(流式 SSE) + // 3. 解析响应,推送 StreamEventTextDelta / StreamEventToolCallStart + // 4. 返回 domain.ChatResponse +} + +// Driver 返回驱动定义,供 Registry 注册使用。 +func Driver() domain.DriverDefinition { + return domain.DriverDefinition{ + Name: Name, + Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (domain.Provider, error) { + return New(cfg) + }, + } +} + +// BuiltinConfig 返回内建配置。 +func BuiltinConfig() config.ProviderConfig { + return config.ProviderConfig{ + Name: Name, + Driver: DriverName, + BaseURL: DefaultBaseURL, + Model: DefaultModel, + Models: []string{DefaultModel, "claude-opus-4-20250514"}, + APIKeyEnv: DefaultAPIKeyEnv, + } +} +``` + +**2. 在 `internal/provider/builtin/builtin.go` 中注册驱动:** + +```go +import "neo-code/internal/provider/anthropic" + +func Register(registry *provider.Registry) error { + if registry == nil { + return errors.New("builtin provider registry is nil") + } + if err := registry.Register(openai.Driver()); err != nil { + return err + } + return registry.Register(anthropic.Driver()) // 新增驱动注册 +} +``` + +## 关键接口与类型 + +| 类型 | 位置 | 说明 | +|------|------|------| +| `Provider` 接口 | `internal/provider/provider.go` | 核心接口,定义 `Chat` 方法 | +| `ChatRequest` | `internal/provider/types.go` | 请求结构:`Model`、`SystemPrompt`、`Messages`、`Tools` | +| `ChatResponse` | `internal/provider/types.go` | 响应结构:`Message`、`FinishReason`、`Usage` | +| `StreamEvent` | `internal/provider/provider.go` | 流式事件:`text_delta` 和 `tool_call_start` | +| `ProviderConfig` | `internal/config/model.go` | 配置层:`Name`、`Driver`、`BaseURL`、`Model`、`Models`、`APIKeyEnv` | +| `DriverDefinition` | `internal/provider/registry.go` | 驱动层:`Name` + `Build` 构造函数 | +| `Registry` | `internal/provider/registry.go` | 驱动注册中心 | + +## 设计约束 + +**必须遵守**: + +- **API Key 只从环境变量读取**,不写入 `config.yaml`,不硬编码在源码中 +- **驱动层不持有模型列表**,`Models` 完全由配置层(`ProviderConfig.Models`)控制 +- **厂商差异收敛在 `internal/provider/` 内**,`runtime`、`tui` 等上层模块只依赖统一的 `Provider` 接口 +- **`base_url` 不向用户暴露**,用户在 TUI 中只能看到 provider 名称和模型列表 + +## 相关文档 + +- [Provider Interface Target Design](../provider-interface-target-design.md) +- [Provider Module Interface](../provider-module-interface.md) +- [Provider Schema Strategy](../provider-schema-strategy.md) diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md new file mode 100644 index 00000000..01a4d1e0 --- /dev/null +++ b/docs/guides/configuration.md @@ -0,0 +1,89 @@ +# 配置指南 + +本文档说明 NeoCode 的配置策略和配置文件结构。 + +## Provider 策略 + +NeoCode 采用"内建 Provider 优先"的策略: + +- 内建 provider 定义随代码版本发布 +- `config.yaml` 不再持久化完整 `providers` 列表 +- `config.yaml` 只保存当前选择状态和通用运行配置 +- 运行时的 `providers` 完全来自代码内建定义 +- API Key 只从环境变量读取,不写入 YAML +- 当前内建 provider 包括 `openai` 和 `gemini` +- `gemini` 复用 OpenAI-compatible driver,请求地址指向 Gemini 的兼容接口 +- provider 实例自己定义 `base_url`、默认模型、可选模型列表和 `api_key_env` +- `base_url` 不在 TUI 中展示给用户 +- driver 只负责协议构造与响应解析,不决定 `models`、`base_url` 或 `api_key_env` + +### 设计理念 + +这意味着: + +- 新用户启动后会自动拿到当前版本最新的内建 provider +- 未来代码新增 provider 时,新用户不需要修改 YAML +- 老配置文件中的 `providers` / `provider_overrides` 会在加载时被清理为新的最小状态格式 + +## 配置文件 + +### 默认路径 + +``` +~/.neocode/config.yaml +``` + +### 配置结构示例 + +```yaml +selected_provider: openai +current_model: gpt-5.4 +workdir: . +shell: powershell +max_loops: 8 +tool_timeout_sec: 20 +``` + +### 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `selected_provider` | string | 当前选择的 provider 名称 | +| `current_model` | string | 当前选择的模型名称 | +| `workdir` | string | 工作目录路径 | +| `shell` | string | Shell 类型(如 `powershell`、`bash`) | +| `max_loops` | int | Agent 推理循环最大轮数 | +| `tool_timeout_sec` | int | 工具执行超时时间(秒) | + +### 环境变量 + +每个 provider 对应的环境变量: + +| Provider | 环境变量 | 说明 | +|----------|----------|------| +| `openai` | `OPENAI_API_KEY` | OpenAI API Key | +| `gemini` | `GEMINI_API_KEY` | Google Gemini API Key | + +**安全提示**: +- API Key 仅从环境变量读取,永不写入配置文件 +- 不要将 API Key 硬编码在代码中 +- 建议使用 `.env` 文件或系统环境变量管理密钥 + +## Slash Commands + +NeoCode 提供以下 slash 命令用于快速切换配置: + +- `/provider` — 打开 provider 选择器 +- `/model` — 打开当前 provider 的模型选择器 + +## 配置管理 + +配置管理由 `internal/config` 模块负责: + +- YAML 加载与保存 +- `.env` 文件集成 +- 默认值管理 +- 并发安全访问 +- 配置校验 + +详细设计参见 [`config-management-detail-design.md`](../config-management-detail-design.md)。 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index cc4a9e49..67e586a6 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -34,7 +34,7 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { providerService := provider.NewService(manager, providerRegistry) sessionStore := agentruntime.NewSessionStore(loader.BaseDir()) - runtimeSvc := agentruntime.NewWithFactory(manager, toolRegistry, sessionStore, providerRegistry) + runtimeSvc := agentruntime.NewWithFactory(manager, toolRegistry, sessionStore, providerService) tuiApp, err := tui.New(&cfg, manager, runtimeSvc, providerService) if err != nil { diff --git a/internal/config/model.go b/internal/config/model.go index 6f10dccd..3273697a 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -39,7 +39,7 @@ type Config struct { type ProviderConfig struct { Name string `yaml:"name"` - Driver string `yaml:"type"` + Driver string `yaml:"driver"` BaseURL string `yaml:"base_url"` Model string `yaml:"model"` Models []string `yaml:"models,omitempty"` @@ -492,20 +492,6 @@ func normalizeModelIDs(models []string) []string { return normalized } -func equalModelIDs(left []string, right []string) bool { - left = normalizeModelIDs(left) - right = normalizeModelIDs(right) - if len(left) != len(right) { - return false - } - for i := range left { - if !strings.EqualFold(left[i], right[i]) { - return false - } - } - return true -} - func containsModelID(models []string, model string) bool { target := strings.ToLower(strings.TrimSpace(model)) if target == "" { diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index b4028d66..4e255880 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "sort" "strings" @@ -97,7 +98,12 @@ func (p *Provider) Chat(ctx context.Context, req domain.ChatRequest, events chan if err != nil { return domain.ChatResponse{}, fmt.Errorf("openai provider: send request: %w", err) } - defer resp.Body.Close() + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + log.Printf("openai provider: close response body: %v", err) + } + }(resp.Body) if resp.StatusCode >= http.StatusBadRequest { return domain.ChatResponse{}, p.parseError(resp) @@ -205,7 +211,11 @@ func (p *Provider) consumeStream(ctx context.Context, body io.Reader, events cha } } - mergeToolCallDeltas(toolCalls, choice.Delta.ToolCalls) + for _, discovered := range mergeToolCallDeltas(toolCalls, choice.Delta.ToolCalls) { + if err := emitToolCallStart(ctx, events, discovered.ID, discovered.Name); err != nil { + return err + } + } } return nil @@ -302,10 +312,30 @@ func emitTextDelta(ctx context.Context, events chan<- domain.StreamEvent, text s } } -func mergeToolCallDeltas(target map[int]*domain.ToolCall, deltas []toolCallDelta) { +func emitToolCallStart(ctx context.Context, events chan<- domain.StreamEvent, id, name string) error { + if events == nil || name == "" { + return nil + } + + select { + case events <- domain.StreamEvent{ + Type: domain.StreamEventToolCallStart, + ToolCallID: id, + ToolName: name, + }: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// mergeToolCallDeltas 将流式增量合并到 target map 中,并返回本次新发现的 tool call 列表。 +// 当一个新的 tool call 索引首次出现且拥有非空工具名称时,视为新发现。 +func mergeToolCallDeltas(target map[int]*domain.ToolCall, deltas []toolCallDelta) []domain.ToolCall { + var discovered []domain.ToolCall for _, delta := range deltas { - call, ok := target[delta.Index] - if !ok { + call, exists := target[delta.Index] + if !exists { call = &domain.ToolCall{} target[delta.Index] = call } @@ -319,7 +349,12 @@ func mergeToolCallDeltas(target map[int]*domain.ToolCall, deltas []toolCallDelta if delta.Function.Arguments != "" { call.Arguments += delta.Function.Arguments } + + if !exists && strings.TrimSpace(call.Name) != "" { + discovered = append(discovered, *call) + } } + return discovered } func finalizeResponse(content string, toolCalls map[int]*domain.ToolCall, finishReason string, usage domain.Usage) domain.ChatResponse { diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index e8fa383b..6843f704 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -36,9 +36,10 @@ func TestMergeToolCallDeltas(t *testing.T) { t.Parallel() tests := []struct { - name string - deltas []toolCallDelta - assert func(t *testing.T, calls map[int]*domain.ToolCall) + name string + deltas []toolCallDelta + assert func(t *testing.T, calls map[int]*domain.ToolCall) + assertDisc func(t *testing.T, disc []domain.ToolCall) }{ { name: "single tool call fragments are merged by index", @@ -60,6 +61,15 @@ func TestMergeToolCallDeltas(t *testing.T) { t.Fatalf("unexpected arguments: %q", call.Arguments) } }, + assertDisc: func(t *testing.T, disc []domain.ToolCall) { + t.Helper() + if len(disc) != 1 { + t.Fatalf("expected 1 discovered tool call, got %d", len(disc)) + } + if disc[0].ID != "call_1" || disc[0].Name != "filesystem_edit" { + t.Fatalf("unexpected discovered call: %+v", disc[0]) + } + }, }, { name: "multiple indices stay isolated", @@ -77,6 +87,12 @@ func TestMergeToolCallDeltas(t *testing.T) { t.Fatalf("unexpected second arguments: %q", calls[1].Arguments) } }, + assertDisc: func(t *testing.T, disc []domain.ToolCall) { + t.Helper() + if len(disc) != 2 { + t.Fatalf("expected 2 discovered tool calls, got %d", len(disc)) + } + }, }, } @@ -85,8 +101,11 @@ func TestMergeToolCallDeltas(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() calls := map[int]*domain.ToolCall{} - mergeToolCallDeltas(calls, tt.deltas) + discovered := mergeToolCallDeltas(calls, tt.deltas) tt.assert(t, calls) + if tt.assertDisc != nil { + tt.assertDisc(t, discovered) + } }) } } @@ -505,3 +524,232 @@ func containsString(items []string, target string) bool { } return false } + +// --- emitToolCallStart / mergeToolCallDeltas 边界补充测试 --- + +func TestEmitToolCallStartGuards(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // nil events 守卫 + if err := emitToolCallStart(ctx, nil, "call-1", "filesystem_edit"); err != nil { + t.Fatalf("expected nil events guard to return nil, got %v", err) + } + + // 空 name 守卫 + events := make(chan domain.StreamEvent, 1) + if err := emitToolCallStart(ctx, events, "call-1", ""); err != nil { + t.Fatalf("expected empty name guard to return nil, got %v", err) + } + select { + case <-events: + t.Fatalf("expected no event for empty name") + default: + } + + // 正常发送 + if err := emitToolCallStart(ctx, events, "call-1", "filesystem_edit"); err != nil { + t.Fatalf("emitToolCallStart() error = %v", err) + } + got := <-events + if got.Type != domain.StreamEventToolCallStart || got.ToolName != "filesystem_edit" || got.ToolCallID != "call-1" { + t.Fatalf("unexpected event: %+v", got) + } + + // context 取消 + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + if err := emitToolCallStart(cancelledCtx, make(chan domain.StreamEvent), "call-1", "filesystem_edit"); err == nil { + t.Fatalf("expected cancellation error") + } +} + +func TestMergeToolCallDeltasEdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + deltas []toolCallDelta + assert func(t *testing.T, calls map[int]*domain.ToolCall) + assertDisc func(t *testing.T, disc []domain.ToolCall) + }{ + { + name: "empty deltas returns empty discovered", + deltas: []toolCallDelta{}, + assert: func(t *testing.T, calls map[int]*domain.ToolCall) { + t.Helper() + if len(calls) != 0 { + t.Fatalf("expected empty calls, got %d", len(calls)) + } + }, + assertDisc: func(t *testing.T, disc []domain.ToolCall) { + t.Helper() + if len(disc) != 0 { + t.Fatalf("expected empty discovered, got %d", len(disc)) + } + }, + }, + { + name: "only ID without Name is not discovered", + deltas: []toolCallDelta{ + {Index: 0, ID: "call-1"}, + }, + assert: func(t *testing.T, calls map[int]*domain.ToolCall) { + t.Helper() + if calls[0].ID != "call-1" { + t.Fatalf("expected ID to be set") + } + }, + assertDisc: func(t *testing.T, disc []domain.ToolCall) { + t.Helper() + if len(disc) != 0 { + t.Fatalf("expected 0 discovered when only ID, got %d", len(disc)) + } + }, + }, + { + name: "only Name without ID is discovered", + deltas: []toolCallDelta{ + {Index: 0, Function: openAIFunctionCall{Name: "filesystem_edit"}}, + }, + assert: func(t *testing.T, calls map[int]*domain.ToolCall) { + t.Helper() + if calls[0].Name != "filesystem_edit" { + t.Fatalf("expected Name to be set") + } + }, + assertDisc: func(t *testing.T, disc []domain.ToolCall) { + t.Helper() + if len(disc) != 1 { + t.Fatalf("expected 1 discovered, got %d", len(disc)) + } + if disc[0].Name != "filesystem_edit" { + t.Fatalf("expected discovered Name %q, got %q", "filesystem_edit", disc[0].Name) + } + }, + }, + { + name: "only Arguments without Name is not discovered", + deltas: []toolCallDelta{ + {Index: 0, Function: openAIFunctionCall{Arguments: `{"path":"x"}`}}, + }, + assert: func(t *testing.T, calls map[int]*domain.ToolCall) { + t.Helper() + if calls[0].Arguments != `{"path":"x"}` { + t.Fatalf("expected Arguments to be set") + } + }, + assertDisc: func(t *testing.T, disc []domain.ToolCall) { + t.Helper() + if len(disc) != 0 { + t.Fatalf("expected 0 discovered when only Arguments, got %d", len(disc)) + } + }, + }, + { + name: "cumulative calls across multiple rounds", + deltas: []toolCallDelta{ + {Index: 0, ID: "call-1", Function: openAIFunctionCall{Name: "tool_a"}}, + }, + assert: func(t *testing.T, calls map[int]*domain.ToolCall) { + t.Helper() + // 第二轮:添加新工具 + discovered := mergeToolCallDeltas(calls, []toolCallDelta{ + {Index: 1, ID: "call-2", Function: openAIFunctionCall{Name: "tool_b"}}, + }) + if len(discovered) != 1 || discovered[0].Name != "tool_b" { + t.Fatalf("expected 1 new discovered in second round, got %+v", discovered) + } + if len(calls) != 2 { + t.Fatalf("expected 2 calls after cumulative merge, got %d", len(calls)) + } + }, + assertDisc: func(t *testing.T, disc []domain.ToolCall) { + t.Helper() + if len(disc) != 1 || disc[0].ID != "call-1" { + t.Fatalf("expected first round discovered ID %q, got %+v", "call-1", disc) + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + calls := map[int]*domain.ToolCall{} + discovered := mergeToolCallDeltas(calls, tt.deltas) + tt.assert(t, calls) + if tt.assertDisc != nil { + tt.assertDisc(t, discovered) + } + }) + } +} + +func TestProviderChatEmitsToolCallStartEvent(t *testing.T) { + t.Setenv(DefaultAPIKeyEnv, "test-key") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + writeSSEChunk(t, w, map[string]any{ + "choices": []map[string]any{ + { + "index": 0, + "delta": map[string]any{ + "tool_calls": []map[string]any{ + { + "index": 0, + "id": "call_tool", + "type": "function", + "function": map[string]any{ + "name": "filesystem_edit", + "arguments": `{}`, + }, + }, + }, + }, + }, + }, + }) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer server.Close() + + provider, err := New(resolvedConfig(server.URL, DefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + provider.client = server.Client() + + events := make(chan domain.StreamEvent, 8) + _, err = provider.Chat(context.Background(), domain.ChatRequest{ + Model: DefaultModel, + Messages: []domain.Message{{Role: "user", Content: "edit"}}, + Tools: []domain.ToolSpec{ + {Name: "filesystem_edit", Description: "edit", Schema: map[string]any{"type": "object"}}, + }, + }, events) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + close(events) + + var foundToolCallStart bool + for evt := range events { + if evt.Type == domain.StreamEventToolCallStart { + foundToolCallStart = true + if evt.ToolName != "filesystem_edit" { + t.Fatalf("expected ToolName %q, got %q", "filesystem_edit", evt.ToolName) + } + if evt.ToolCallID != "call_tool" { + t.Fatalf("expected ToolCallID %q, got %q", "call_tool", evt.ToolCallID) + } + } + } + if !foundToolCallStart { + t.Fatalf("expected StreamEventToolCallStart event in stream") + } +} diff --git a/internal/provider/openll/openll.go b/internal/provider/openll/openll.go index 7b48ac08..ac99cdd9 100644 --- a/internal/provider/openll/openll.go +++ b/internal/provider/openll/openll.go @@ -1,40 +1,21 @@ package openll import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "sort" - "strings" - "time" - "neo-code/internal/config" - domain "neo-code/internal/provider" ) -type Provider struct { - cfg config.ResolvedProviderConfig - client *http.Client -} - const ( Name = "openll" DriverName = "openai" DefaultBaseURL = "https://www.openll.top/v1" - DefaultModel = "gpt-4.1" + DefaultModel = "gpt-5.4" DefaultAPIKeyEnv = "AI_API_KEY" ) var builtinModels = []string{ DefaultModel, - "gpt-4o", - "gpt-5.4", "gpt-5.3-codex", + "gpt-5.3-turbo", } func BuiltinConfig() config.ProviderConfig { @@ -47,382 +28,3 @@ func BuiltinConfig() config.ProviderConfig { APIKeyEnv: DefaultAPIKeyEnv, } } - -func Driver() domain.DriverDefinition { - return domain.DriverDefinition{ - Name: Name, - Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (domain.Provider, error) { - return New(cfg) - }, - } -} - -func New(cfg config.ResolvedProviderConfig) (*Provider, error) { - if err := cfg.Validate(); err != nil { - return nil, fmt.Errorf("openai provider: %w", err) - } - if strings.TrimSpace(cfg.APIKey) == "" { - return nil, errors.New("openai provider: api key is empty") - } - - return &Provider{ - cfg: cfg, - client: &http.Client{ - Timeout: 90 * time.Second, - }, - }, nil -} - -func (p *Provider) Chat(ctx context.Context, req domain.ChatRequest, events chan<- domain.StreamEvent) (domain.ChatResponse, error) { - payload, err := p.buildRequest(req) - if err != nil { - return domain.ChatResponse{}, err - } - - body, err := json.Marshal(payload) - if err != nil { - return domain.ChatResponse{}, fmt.Errorf("openai provider: marshal request: %w", err) - } - - endpoint := strings.TrimRight(p.cfg.BaseURL, "/") + "/chat/completions" - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return domain.ChatResponse{}, fmt.Errorf("openai provider: build request: %w", err) - } - httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Accept", "text/event-stream") - - resp, err := p.client.Do(httpReq) - if err != nil { - return domain.ChatResponse{}, fmt.Errorf("openai provider: send request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= http.StatusBadRequest { - return domain.ChatResponse{}, p.parseError(resp) - } - - return p.consumeStream(ctx, resp.Body, events) -} - -func (p *Provider) buildRequest(req domain.ChatRequest) (chatCompletionRequest, error) { - model := strings.TrimSpace(req.Model) - if model == "" { - model = strings.TrimSpace(p.cfg.Model) - } - if model == "" { - return chatCompletionRequest{}, errors.New("openai provider: model is empty") - } - - payload := chatCompletionRequest{ - Model: model, - Stream: true, - Messages: make([]openAIMessage, 0, len(req.Messages)+1), - } - - if strings.TrimSpace(req.SystemPrompt) != "" { - payload.Messages = append(payload.Messages, openAIMessage{ - Role: "system", - Content: req.SystemPrompt, - }) - } - - for _, message := range req.Messages { - payload.Messages = append(payload.Messages, toOpenAIMessage(message)) - } - - if len(req.Tools) > 0 { - payload.ToolChoice = "auto" - payload.Tools = make([]openAIToolDefinition, 0, len(req.Tools)) - for _, spec := range req.Tools { - payload.Tools = append(payload.Tools, openAIToolDefinition{ - Type: "function", - Function: openAIFunctionDefinition{ - Name: spec.Name, - Description: spec.Description, - Parameters: spec.Schema, - }, - }) - } - } - - return payload, nil -} - -func (p *Provider) consumeStream(ctx context.Context, body io.Reader, events chan<- domain.StreamEvent) (domain.ChatResponse, error) { - reader := bufio.NewReader(body) - - var ( - contentBuilder strings.Builder - finishReason string - usage domain.Usage - done bool - ) - - toolCalls := make(map[int]*domain.ToolCall) - dataLines := make([]string, 0, 4) - - flushEvent := func() error { - if len(dataLines) == 0 { - return nil - } - - payload := strings.Join(dataLines, "\n") - dataLines = dataLines[:0] - - if strings.TrimSpace(payload) == "[DONE]" { - done = true - return nil - } - - var chunk chatCompletionChunk - if err := json.Unmarshal([]byte(payload), &chunk); err != nil { - return fmt.Errorf("openai provider: decode stream chunk: %w", err) - } - - if chunk.Error != nil && strings.TrimSpace(chunk.Error.Message) != "" { - return errors.New(chunk.Error.Message) - } - - if chunk.Usage != nil { - usage = domain.Usage{ - InputTokens: chunk.Usage.PromptTokens, - OutputTokens: chunk.Usage.CompletionTokens, - TotalTokens: chunk.Usage.TotalTokens, - } - } - - for _, choice := range chunk.Choices { - if strings.TrimSpace(choice.FinishReason) != "" { - finishReason = choice.FinishReason - } - - if text := choice.Delta.Content; text != "" { - contentBuilder.WriteString(text) - if err := emitTextDelta(ctx, events, text); err != nil { - return err - } - } - - mergeToolCallDeltas(toolCalls, choice.Delta.ToolCalls) - } - - return nil - } - - for { - line, err := reader.ReadString('\n') - if err != nil && !errors.Is(err, io.EOF) { - return domain.ChatResponse{}, fmt.Errorf("openai provider: read stream: %w", err) - } - - line = strings.TrimRight(line, "\r\n") - trimmed := strings.TrimSpace(line) - - switch { - case strings.HasPrefix(trimmed, "data:"): - dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))) - case trimmed == "": - if flushErr := flushEvent(); flushErr != nil { - return domain.ChatResponse{}, flushErr - } - if done { - return finalizeResponse(contentBuilder.String(), toolCalls, finishReason, usage), nil - } - case strings.HasPrefix(trimmed, ":"): - // SSE comment/heartbeat; ignore it. - } - - if errors.Is(err, io.EOF) { - if flushErr := flushEvent(); flushErr != nil { - return domain.ChatResponse{}, flushErr - } - return finalizeResponse(contentBuilder.String(), toolCalls, finishReason, usage), nil - } - } -} - -func (p *Provider) parseError(resp *http.Response) error { - data, readErr := io.ReadAll(resp.Body) - if readErr != nil { - return fmt.Errorf("openai provider: read error response: %w", readErr) - } - - var parsed openAIErrorResponse - if err := json.Unmarshal(data, &parsed); err == nil && strings.TrimSpace(parsed.Error.Message) != "" { - return errors.New(parsed.Error.Message) - } - - bodyText := strings.TrimSpace(string(data)) - if bodyText == "" { - return errors.New(resp.Status) - } - - return fmt.Errorf("openai provider: %s", bodyText) -} - -func toOpenAIMessage(message domain.Message) openAIMessage { - out := openAIMessage{ - Role: message.Role, - Content: message.Content, - ToolCallID: message.ToolCallID, - } - - if len(message.ToolCalls) > 0 { - out.ToolCalls = make([]openAIToolCall, 0, len(message.ToolCalls)) - for _, call := range message.ToolCalls { - out.ToolCalls = append(out.ToolCalls, openAIToolCall{ - ID: call.ID, - Type: "function", - Function: openAIFunctionCall{ - Name: call.Name, - Arguments: call.Arguments, - }, - }) - } - } - - return out -} - -func emitTextDelta(ctx context.Context, events chan<- domain.StreamEvent, text string) error { - if events == nil || text == "" { - return nil - } - - select { - case events <- domain.StreamEvent{ - Type: domain.StreamEventTextDelta, - Text: text, - }: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -func mergeToolCallDeltas(target map[int]*domain.ToolCall, deltas []toolCallDelta) { - for _, delta := range deltas { - call, ok := target[delta.Index] - if !ok { - call = &domain.ToolCall{} - target[delta.Index] = call - } - - if strings.TrimSpace(delta.ID) != "" { - call.ID = delta.ID - } - if strings.TrimSpace(delta.Function.Name) != "" { - call.Name = delta.Function.Name - } - if delta.Function.Arguments != "" { - call.Arguments += delta.Function.Arguments - } - } -} - -func finalizeResponse(content string, toolCalls map[int]*domain.ToolCall, finishReason string, usage domain.Usage) domain.ChatResponse { - ordered := make([]int, 0, len(toolCalls)) - for index := range toolCalls { - ordered = append(ordered, index) - } - sort.Ints(ordered) - - message := domain.Message{ - Role: "assistant", - Content: content, - } - - for _, index := range ordered { - call := toolCalls[index] - if call == nil { - continue - } - message.ToolCalls = append(message.ToolCalls, *call) - } - - if finishReason == "" && len(message.ToolCalls) > 0 { - finishReason = "tool_calls" - } - - return domain.ChatResponse{ - Message: message, - FinishReason: finishReason, - Usage: usage, - } -} - -type chatCompletionRequest struct { - Model string `json:"model"` - Messages []openAIMessage `json:"messages"` - Tools []openAIToolDefinition `json:"tools,omitempty"` - ToolChoice string `json:"tool_choice,omitempty"` - Stream bool `json:"stream"` -} - -type openAIMessage struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` - ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` -} - -type openAIToolDefinition struct { - Type string `json:"type"` - Function openAIFunctionDefinition `json:"function"` -} - -type openAIFunctionDefinition struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Parameters map[string]any `json:"parameters,omitempty"` -} - -type openAIToolCall struct { - ID string `json:"id,omitempty"` - Type string `json:"type,omitempty"` - Function openAIFunctionCall `json:"function"` -} - -type openAIFunctionCall struct { - Name string `json:"name,omitempty"` - Arguments string `json:"arguments,omitempty"` -} - -type chatCompletionChunk struct { - Choices []struct { - Index int `json:"index"` - Delta chunkDelta `json:"delta"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage *openAIUsage `json:"usage,omitempty"` - Error *struct { - Message string `json:"message"` - } `json:"error,omitempty"` -} - -type chunkDelta struct { - Role string `json:"role,omitempty"` - Content string `json:"content,omitempty"` - ToolCalls []toolCallDelta `json:"tool_calls,omitempty"` -} - -type toolCallDelta struct { - Index int `json:"index"` - ID string `json:"id,omitempty"` - Type string `json:"type,omitempty"` - Function openAIFunctionCall `json:"function"` -} - -type openAIUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` -} - -type openAIErrorResponse struct { - Error struct { - Message string `json:"message"` - } `json:"error"` -} diff --git a/internal/provider/openll/openll_test.go b/internal/provider/openll/openll_test.go index ac77cfbb..379b241f 100644 --- a/internal/provider/openll/openll_test.go +++ b/internal/provider/openll/openll_test.go @@ -1,111 +1,29 @@ package openll import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" "testing" - "neo-code/internal/config" - domain "neo-code/internal/provider" + "neo-code/internal/provider/openai" ) -func resolvedConfig(baseURL string, model string) config.ResolvedProviderConfig { - if strings.TrimSpace(baseURL) == "" { - baseURL = DefaultBaseURL - } - if strings.TrimSpace(model) == "" { - model = DefaultModel - } - - return config.ResolvedProviderConfig{ - ProviderConfig: config.ProviderConfig{ - Name: Name, - Driver: DriverName, - BaseURL: baseURL, - Model: model, - APIKeyEnv: DefaultAPIKeyEnv, - }, - APIKey: "test-key", - } -} - -func TestMergeToolCallDeltas(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - deltas []toolCallDelta - assert func(t *testing.T, calls map[int]*domain.ToolCall) - }{ - { - name: "single tool call fragments are merged by index", - deltas: []toolCallDelta{ - {Index: 0, ID: "call_1", Function: openAIFunctionCall{Name: "filesystem_edit", Arguments: `{"path":"a.go",`}}, - {Index: 0, Function: openAIFunctionCall{Arguments: `"search_string":"old",`}}, - {Index: 0, Function: openAIFunctionCall{Arguments: `"replace_string":"new"}`}}, - }, - assert: func(t *testing.T, calls map[int]*domain.ToolCall) { - t.Helper() - call := calls[0] - if call == nil { - t.Fatalf("expected call at index 0") - } - if call.Name != "filesystem_edit" { - t.Fatalf("expected name filesystem_edit, got %q", call.Name) - } - if call.Arguments != `{"path":"a.go","search_string":"old","replace_string":"new"}` { - t.Fatalf("unexpected arguments: %q", call.Arguments) - } - }, - }, - { - name: "multiple indices stay isolated", - deltas: []toolCallDelta{ - {Index: 0, ID: "call_1", Function: openAIFunctionCall{Name: "filesystem_read_file", Arguments: `{"path":"a.go"}`}}, - {Index: 1, ID: "call_2", Function: openAIFunctionCall{Name: "filesystem_write_file", Arguments: `{"path":"b.go"`}}, - {Index: 1, Function: openAIFunctionCall{Arguments: `,"content":"ok"}`}}, - }, - assert: func(t *testing.T, calls map[int]*domain.ToolCall) { - t.Helper() - if len(calls) != 2 { - t.Fatalf("expected 2 calls, got %d", len(calls)) - } - if calls[1].Arguments != `{"path":"b.go","content":"ok"}` { - t.Fatalf("unexpected second arguments: %q", calls[1].Arguments) - } - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - calls := map[int]*domain.ToolCall{} - mergeToolCallDeltas(calls, tt.deltas) - tt.assert(t, calls) - }) - } -} - -func TestBuiltinConfigIncludesProviderModels(t *testing.T) { +func TestBuiltinConfigUsesOpenAICompatibleDriver(t *testing.T) { t.Parallel() cfg := BuiltinConfig() if cfg.Name != Name { t.Fatalf("expected provider name %q, got %q", Name, cfg.Name) } - if cfg.Driver != DriverName { - t.Fatalf("expected driver %q, got %q", DriverName, cfg.Driver) + if cfg.Driver != openai.DriverName { + t.Fatalf("expected driver %q, got %q", openai.DriverName, cfg.Driver) } if cfg.BaseURL != DefaultBaseURL { t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) } + if cfg.Model != DefaultModel { + t.Fatalf("expected default model %q, got %q", DefaultModel, cfg.Model) + } if cfg.APIKeyEnv != DefaultAPIKeyEnv { - t.Fatalf("expected api key env %q, got %q", DefaultAPIKeyEnv, cfg.APIKeyEnv) + t.Fatalf("expected API key env %q, got %q", DefaultAPIKeyEnv, cfg.APIKeyEnv) } if len(cfg.Models) < 3 { t.Fatalf("expected builtin models to be predeclared, got %+v", cfg.Models) @@ -115,394 +33,6 @@ func TestBuiltinConfigIncludesProviderModels(t *testing.T) { } } -func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { - t.Setenv(DefaultAPIKeyEnv, "test-key") - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/chat/completions" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - if got := r.Header.Get("Authorization"); got != "Bearer test-key" { - t.Fatalf("unexpected auth header: %s", got) - } - - var payload chatCompletionRequest - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("decode request: %v", err) - } - if payload.Model != "gpt-5.4" { - t.Fatalf("expected model gpt-5.4, got %q", payload.Model) - } - if !containsToolRoleMessage(payload.Messages, "call_1", "tool finished") { - t.Fatalf("expected tool role message with tool_call_id in payload: %+v", payload.Messages) - } - - w.Header().Set("Content-Type", "text/event-stream") - writeSSEChunk(t, w, map[string]any{ - "choices": []map[string]any{ - { - "index": 0, - "delta": map[string]any{ - "content": "Hello ", - }, - }, - }, - }) - writeSSEChunk(t, w, map[string]any{ - "choices": []map[string]any{ - { - "index": 0, - "delta": map[string]any{ - "tool_calls": []map[string]any{ - { - "index": 0, - "id": "call_1", - "type": "function", - "function": map[string]any{ - "name": "filesystem_edit", - "arguments": `{"path":"main.go",`, - }, - }, - }, - }, - }, - }, - }) - writeSSEChunk(t, w, map[string]any{ - "choices": []map[string]any{ - { - "index": 0, - "delta": map[string]any{ - "content": "world", - "tool_calls": []map[string]any{ - { - "index": 0, - "function": map[string]any{ - "arguments": `"search_string":"old",`, - }, - }, - }, - }, - }, - }, - }) - writeSSEChunk(t, w, map[string]any{ - "choices": []map[string]any{ - { - "index": 0, - "finish_reason": "tool_calls", - "delta": map[string]any{ - "tool_calls": []map[string]any{ - { - "index": 0, - "function": map[string]any{ - "arguments": `"replace_string":"new"}`, - }, - }, - }, - }, - }, - }, - "usage": map[string]any{ - "prompt_tokens": 10, - "completion_tokens": 5, - "total_tokens": 15, - }, - }) - _, _ = w.Write([]byte("data: [DONE]\n\n")) - })) - defer server.Close() - - provider, err := New(resolvedConfig(server.URL, "gpt-5.4")) - if err != nil { - t.Fatalf("New() error = %v", err) - } - provider.client = server.Client() - - events := make(chan domain.StreamEvent, 8) - response, err := provider.Chat(context.Background(), domain.ChatRequest{ - Model: "gpt-5.4", - Messages: []domain.Message{ - {Role: "user", Content: "please edit the file"}, - { - Role: "assistant", - ToolCalls: []domain.ToolCall{ - { - ID: "call_1", - Name: "filesystem_edit", - Arguments: `{"path":"main.go","search_string":"old","replace_string":"new"}`, - }, - }, - }, - {Role: "tool", ToolCallID: "call_1", Content: "tool finished"}, - }, - Tools: []domain.ToolSpec{ - { - Name: "filesystem_edit", - Description: "Edit one matching block in a file", - Schema: map[string]any{ - "type": "object", - }, - }, - }, - }, events) - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - - if response.Message.Content != "Hello world" { - t.Fatalf("expected content %q, got %q", "Hello world", response.Message.Content) - } - if response.FinishReason != "tool_calls" { - t.Fatalf("expected finish reason tool_calls, got %q", response.FinishReason) - } - if response.Usage.TotalTokens != 15 { - t.Fatalf("expected total tokens 15, got %d", response.Usage.TotalTokens) - } - if len(response.Message.ToolCalls) != 1 { - t.Fatalf("expected 1 tool call, got %d", len(response.Message.ToolCalls)) - } - - call := response.Message.ToolCalls[0] - if call.ID != "call_1" || call.Name != "filesystem_edit" { - t.Fatalf("unexpected tool call: %+v", call) - } - if call.Arguments != `{"path":"main.go","search_string":"old","replace_string":"new"}` { - t.Fatalf("unexpected merged arguments: %q", call.Arguments) - } - - var chunks []string - for { - select { - case event := <-events: - chunks = append(chunks, event.Text) - default: - if strings.Join(chunks, "") != "Hello world" { - t.Fatalf("expected streamed chunks to form %q, got %q", "Hello world", strings.Join(chunks, "")) - } - return - } - } -} - -func TestProviderChatHTTPErrorResponses(t *testing.T) { - t.Setenv(DefaultAPIKeyEnv, "test-key") - - tests := []struct { - name string - status int - body string - expectErr string - }{ - { - name: "http 401 json error", - status: http.StatusUnauthorized, - body: `{"error":{"message":"invalid api key"}}`, - expectErr: "invalid api key", - }, - { - name: "http 500 empty body falls back to status", - status: http.StatusInternalServerError, - body: ``, - expectErr: "500 Internal Server Error", - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(tt.status) - if tt.body != "" { - _, _ = w.Write([]byte(tt.body)) - } - })) - defer server.Close() - - provider, err := New(resolvedConfig(server.URL, DefaultModel)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - provider.client = server.Client() - - _, err = provider.Chat(context.Background(), domain.ChatRequest{ - Model: DefaultModel, - }, make(chan domain.StreamEvent, 1)) - if err == nil || !strings.Contains(err.Error(), tt.expectErr) { - t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) - } - }) - } -} - -func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { - t.Parallel() - - provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - payload, err := provider.buildRequest(domain.ChatRequest{ - SystemPrompt: "system prompt", - Messages: []domain.Message{ - {Role: "user", Content: "hello"}, - { - Role: "assistant", - ToolCalls: []domain.ToolCall{ - { - ID: "call_1", - Name: "filesystem_edit", - Arguments: `{"path":"main.go"}`, - }, - }, - }, - {Role: "tool", ToolCallID: "call_1", Content: "tool finished"}, - }, - Tools: []domain.ToolSpec{ - { - Name: "filesystem_edit", - Description: "Edit file content", - Schema: map[string]any{ - "type": "object", - }, - }, - }, - }) - if err != nil { - t.Fatalf("buildRequest() error = %v", err) - } - - if payload.Model != DefaultModel { - t.Fatalf("expected default model %q, got %q", DefaultModel, payload.Model) - } - if !payload.Stream { - t.Fatalf("expected stream=true") - } - if payload.ToolChoice != "auto" { - t.Fatalf("expected tool choice auto, got %q", payload.ToolChoice) - } - if len(payload.Tools) != 1 || payload.Tools[0].Function.Name != "filesystem_edit" { - t.Fatalf("unexpected tool payload: %+v", payload.Tools) - } - if payload.Messages[0].Role != "system" || payload.Messages[0].Content != "system prompt" { - t.Fatalf("unexpected system message: %+v", payload.Messages[0]) - } - if !containsToolRoleMessage(payload.Messages, "call_1", "tool finished") { - t.Fatalf("expected tool role message, got %+v", payload.Messages) - } - if len(payload.Messages[2].ToolCalls) != 1 || payload.Messages[2].ToolCalls[0].Function.Name != "filesystem_edit" { - t.Fatalf("expected assistant tool call payload, got %+v", payload.Messages[2]) - } -} - -func TestParseErrorAndEmitTextDelta(t *testing.T) { - t.Parallel() - - provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - tests := []struct { - name string - status string - body string - expectErr string - }{ - { - name: "json error payload", - status: "400 Bad Request", - body: `{"error":{"message":"invalid request"}}`, - expectErr: "invalid request", - }, - { - name: "plain text fallback", - status: "502 Bad Gateway", - body: `gateway timeout`, - expectErr: "gateway timeout", - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - resp := &http.Response{ - Status: tt.status, - Body: ioNopCloser(tt.body), - } - err := provider.parseError(resp) - if err == nil || !strings.Contains(err.Error(), tt.expectErr) { - t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) - } - }) - } - - eventCh := make(chan domain.StreamEvent, 1) - if err := emitTextDelta(context.Background(), eventCh, "chunk"); err != nil { - t.Fatalf("emitTextDelta() error = %v", err) - } - if got := <-eventCh; got.Text != "chunk" || got.Type != domain.StreamEventTextDelta { - t.Fatalf("unexpected stream event: %+v", got) - } - - cancelledCtx, cancel := context.WithCancel(context.Background()) - cancel() - if err := emitTextDelta(cancelledCtx, make(chan domain.StreamEvent), "chunk"); err == nil { - t.Fatalf("expected cancellation error") - } -} - -func TestProviderConsumeStreamRejectsDirtyJSON(t *testing.T) { - t.Parallel() - - provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - _, err = provider.consumeStream(context.Background(), strings.NewReader("data: {not-json}\n\n"), make(chan domain.StreamEvent, 1)) - if err == nil || !strings.Contains(err.Error(), "decode stream chunk") { - t.Fatalf("expected dirty JSON decode error, got %v", err) - } -} - -func containsToolRoleMessage(messages []openAIMessage, toolCallID string, content string) bool { - for _, message := range messages { - if message.Role == "tool" && message.ToolCallID == toolCallID && message.Content == content { - return true - } - } - return false -} - -func writeSSEChunk(t *testing.T, w http.ResponseWriter, payload any) { - t.Helper() - data, err := json.Marshal(payload) - if err != nil { - t.Fatalf("marshal SSE payload: %v", err) - } - if _, err := w.Write([]byte("data: " + string(data) + "\n\n")); err != nil { - t.Fatalf("write SSE payload: %v", err) - } - if flusher, ok := w.(http.Flusher); ok { - flusher.Flush() - } -} - -func ioNopCloser(body string) *readCloser { - return &readCloser{Reader: strings.NewReader(body)} -} - -type readCloser struct { - *strings.Reader -} - -func (r *readCloser) Close() error { - return nil -} - func containsString(items []string, target string) bool { for _, item := range items { if item == target { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 775ae374..531dba4f 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -9,10 +9,16 @@ type Provider interface { type StreamEventType string const ( + // StreamEventTextDelta 表示模型输出的文本片段。 StreamEventTextDelta StreamEventType = "text_delta" + // StreamEventToolCallStart 表示模型开始请求工具调用,TUI 可据此展示过渡提示。 + StreamEventToolCallStart StreamEventType = "tool_call_start" ) +// StreamEvent 表示 provider 驱动层向 runtime 推送的流式事件。 type StreamEvent struct { - Type StreamEventType - Text string + Type StreamEventType + Text string // 文本片段(text_delta 时使用) + ToolName string // 工具名称(tool_call_start 时使用) + ToolCallID string // 工具调用 ID(tool_call_start 时使用) } diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 3bda125d..6f209301 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -282,3 +282,52 @@ func TestServiceSelectProviderRejectsUnsupportedDriver(t *testing.T) { t.Fatalf("expected SelectProvider() to preserve ErrDriverNotFound, got %v", err) } } + +// --- Service.Build 补充测试 --- + +func TestServiceBuildDelegatesToRegistry(t *testing.T) { + t.Parallel() + + registry := newTestRegistry(t) + + resolved := config.ResolvedProviderConfig{ + ProviderConfig: config.ProviderConfig{ + Name: "openai-main", + Driver: "openai", + BaseURL: openai.DefaultBaseURL, + Model: openai.DefaultModel, + APIKeyEnv: openai.DefaultAPIKeyEnv, + }, + APIKey: "test-key", + } + + got, err := registry.Build(context.Background(), resolved) + if err != nil { + t.Fatalf("Registry.Build() error = %v", err) + } + if _, ok := got.(*openai.Provider); !ok { + t.Fatalf("expected openai.Provider, got %T", got) + } +} + +func TestServiceBuildReturnsErrorOnNilManager(t *testing.T) { + t.Parallel() + + service := (*provider.Service)(nil) + _, err := service.Build(context.Background(), config.ResolvedProviderConfig{}) + if err == nil { + t.Fatalf("expected error for nil Service") + } +} + +func TestServiceBuildReturnsErrorOnNilRegistry(t *testing.T) { + t.Parallel() + + manager := newTestManager(t) + service := provider.NewService(manager, nil) + + _, err := service.Build(context.Background(), config.ResolvedProviderConfig{}) + if err == nil { + t.Fatalf("expected error for nil registry") + } +} diff --git a/internal/provider/service.go b/internal/provider/service.go index d0ae3ae6..8c2f983c 100644 --- a/internal/provider/service.go +++ b/internal/provider/service.go @@ -176,6 +176,15 @@ func modelDescriptors(models []string) []ModelDescriptor { return descriptors } +// Build 根据已解析的 provider 配置构建 Provider 实例。 +// 使 Service 满足 runtime.ProviderFactory 接口,简化装配层依赖。 +func (s *Service) Build(ctx context.Context, cfg config.ResolvedProviderConfig) (Provider, error) { + if err := s.validate(); err != nil { + return nil, err + } + return s.registry.Build(ctx, cfg) +} + func (s *Service) validate() error { if s == nil || s.manager == nil { return errServiceManagerNil diff --git a/internal/runtime/events.go b/internal/runtime/events.go index bcb3cb12..4cd162c5 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -30,10 +30,7 @@ const ( EventRunCanceled EventType = "run_canceled" // EventError is emitted for terminal runtime errors other than cancellation. EventError EventType = "error" -) - -const ( - EventToolStarted = EventToolStart - EventToolFinished = EventToolResult - EventAgentComplete = EventAgentDone + // EventToolCallThinking is emitted when the model decides to call a tool, + // before the tool execution begins. TUI can show a transitional indicator. + EventToolCallThinking EventType = "tool_call_thinking" ) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 44a6738c..193e7295 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -14,7 +14,6 @@ import ( const maxContextTurns = 10 -// Runtime coordinates agent execution, session persistence, and event delivery. type Runtime interface { Run(ctx context.Context, input UserInput) error CancelActiveRun() bool @@ -23,8 +22,6 @@ type Runtime interface { LoadSession(ctx context.Context, id string) (Session, error) } -// UserInput describes a single user turn. RunID is supplied by the caller and -// is echoed on all runtime events produced while handling this input. type UserInput struct { SessionID string RunID string @@ -36,15 +33,15 @@ type ProviderFactory interface { } type Service struct { - configManager *config.Manager - sessionStore Store - toolRegistry *tools.Registry - providerFactory ProviderFactory - events chan RuntimeEvent - runMu sync.Mutex - activeRunToken uint64 - nextRunToken uint64 - activeRunCancel context.CancelFunc + configManager *config.Manager //配置管理器,提供当前选中的 provider、model、workdir 等配置读取能力 + sessionStore Store //会话持久化接口,负责保存和加载聊天会话 + toolRegistry *tools.Registry //工具注册表,维护所有工具的schema + providerFactory ProviderFactory //provider 工厂接口,根据配置动态创建具体的 provider 实例 + events chan RuntimeEvent //事件通道,Runtime 在运行过程中产生的所有事件都通过这个 channel 发送给 TUI 层消费和展示 + runMu sync.Mutex //运行互斥锁,保证同一时间只有一个 Run 在执行 + activeRunToken uint64 //当前活跃运行的令牌标识,用于标记正在执行的 Run 实例,配合 nextRunToken 实现新旧 Run 的安全切换 + nextRunToken uint64 //下一个运行令牌的递增计数器,每次启动新 Run 时递增并赋给 activeRunToken,用于区分不同 Run 的生命周期 + activeRunCancel context.CancelFunc //当前活跃 Run 的取消函数 } func NewWithFactory(configManager *config.Manager, toolRegistry *tools.Registry, sessionStore Store, providerFactory ProviderFactory) *Service { @@ -76,7 +73,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { session, err := s.loadOrCreateSession(ctx, input.SessionID, input.Content) if err != nil { - return s.handleRunError(input.RunID, input.SessionID, err) + return s.handleRunError(ctx, input.RunID, input.SessionID, err) } userMessage := provider.Message{ @@ -86,13 +83,13 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { session.Messages = append(session.Messages, userMessage) session.UpdatedAt = time.Now() if err := s.sessionStore.Save(ctx, &session); err != nil { - return s.handleRunError(input.RunID, session.ID, err) + return s.handleRunError(ctx, input.RunID, session.ID, err) } - s.emit(EventUserMessage, input.RunID, session.ID, userMessage) + s.emit(ctx, EventUserMessage, input.RunID, session.ID, userMessage) for attempt := 0; ; attempt++ { if err := ctx.Err(); err != nil { - return s.handleRunError(input.RunID, session.ID, err) + return s.handleRunError(ctx, input.RunID, session.ID, err) } cfg := s.configManager.Get() @@ -102,25 +99,25 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } if attempt >= maxLoops { err := errors.New("runtime: max loop reached") - s.emit(EventError, input.RunID, session.ID, err.Error()) + s.emit(ctx, EventError, input.RunID, session.ID, err.Error()) return err } resolvedProvider, err := s.configManager.ResolvedSelectedProvider() if err != nil { - s.emit(EventError, input.RunID, session.ID, err.Error()) + s.emit(ctx, EventError, input.RunID, session.ID, err.Error()) return err } modelProvider, err := s.providerFactory.Build(ctx, resolvedProvider) if err != nil { - s.emit(EventError, input.RunID, session.ID, err.Error()) + s.emit(ctx, EventError, input.RunID, session.ID, err.Error()) return err } streamEvents := make(chan provider.StreamEvent, 32) streamDone := make(chan struct{}) - go s.forwardProviderEvents(input.RunID, session.ID, streamEvents, streamDone) + go s.forwardProviderEvents(ctx, input.RunID, session.ID, streamEvents, streamDone) resp, err := modelProvider.Chat(ctx, provider.ChatRequest{ Model: cfg.CurrentModel, @@ -131,10 +128,10 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { close(streamEvents) <-streamDone if err != nil { - return s.handleRunError(input.RunID, session.ID, err) + return s.handleRunError(ctx, input.RunID, session.ID, err) } if err := ctx.Err(); err != nil { - return s.handleRunError(input.RunID, session.ID, err) + return s.handleRunError(ctx, input.RunID, session.ID, err) } assistant := resp.Message @@ -146,23 +143,23 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { session.Messages = append(session.Messages, assistant) session.UpdatedAt = time.Now() if err := s.sessionStore.Save(ctx, &session); err != nil { - return s.handleRunError(input.RunID, session.ID, err) + return s.handleRunError(ctx, input.RunID, session.ID, err) } } if err := ctx.Err(); err != nil { - return s.handleRunError(input.RunID, session.ID, err) + return s.handleRunError(ctx, input.RunID, session.ID, err) } if len(assistant.ToolCalls) == 0 { - s.emit(EventAgentDone, input.RunID, session.ID, assistant) + s.emit(ctx, EventAgentDone, input.RunID, session.ID, assistant) return nil } for _, call := range assistant.ToolCalls { if err := ctx.Err(); err != nil { - return s.handleRunError(input.RunID, session.ID, err) + return s.handleRunError(ctx, input.RunID, session.ID, err) } - s.emit(EventToolStart, input.RunID, session.ID, call) + s.emit(ctx, EventToolStart, input.RunID, session.ID, call) runCtx, cancel := context.WithTimeout(ctx, time.Duration(cfg.ToolTimeoutSec)*time.Second) result, execErr := s.toolRegistry.Execute(runCtx, tools.ToolCallInput{ @@ -172,16 +169,16 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { Workdir: cfg.Workdir, SessionID: session.ID, EmitChunk: func(chunk []byte) { - s.emit(EventToolChunk, input.RunID, session.ID, string(chunk)) + s.emit(ctx, EventToolChunk, input.RunID, session.ID, string(chunk)) }, }) cancel() if s.isRunCanceled(execErr) { - return s.handleRunError(input.RunID, session.ID, execErr) + return s.handleRunError(ctx, input.RunID, session.ID, execErr) } if execErr == nil { if err := ctx.Err(); err != nil { - return s.handleRunError(input.RunID, session.ID, err) + return s.handleRunError(ctx, input.RunID, session.ID, err) } } @@ -199,20 +196,20 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { session.UpdatedAt = time.Now() if err := s.sessionStore.Save(ctx, &session); err != nil { if execErr != nil && errors.Is(err, context.Canceled) { - s.emit(EventToolResult, input.RunID, session.ID, result) + s.emit(ctx, EventToolResult, input.RunID, session.ID, result) } - return s.handleRunError(input.RunID, session.ID, err) + return s.handleRunError(ctx, input.RunID, session.ID, err) } if err := ctx.Err(); err != nil { if execErr == nil { - return s.handleRunError(input.RunID, session.ID, err) + return s.handleRunError(ctx, input.RunID, session.ID, err) } } - s.emit(EventToolResult, input.RunID, session.ID, result) + s.emit(ctx, EventToolResult, input.RunID, session.ID, result) if execErr != nil { if err := ctx.Err(); err != nil { - return s.handleRunError(input.RunID, session.ID, err) + return s.handleRunError(ctx, input.RunID, session.ID, err) } } } @@ -254,21 +251,45 @@ func (s *Service) loadOrCreateSession(ctx context.Context, sessionID string, tit return s.sessionStore.Load(ctx, sessionID) } -func (s *Service) emit(kind EventType, runID string, sessionID string, payload any) { - s.events <- RuntimeEvent{ +// emit 向事件通道发送事件。 +// 先尝试非阻塞发送,确保即使 context 已取消,只要通道有空间事件就能被投递; +// 仅在通道已满时才通过 ctx.Done() 退出,避免 goroutine 泄漏。 +func (s *Service) emit(ctx context.Context, kind EventType, runID string, sessionID string, payload any) { + evt := RuntimeEvent{ Type: kind, RunID: runID, SessionID: sessionID, Payload: payload, } + select { + case s.events <- evt: + return + default: + } + select { + case s.events <- evt: + case <-ctx.Done(): + } } -func (s *Service) forwardProviderEvents(runID string, sessionID string, input <-chan provider.StreamEvent, done chan<- struct{}) { +// forwardProviderEvents 将 provider 流式事件转发为 runtime 事件。 +// 使用 select 同时监听输入通道和 context 取消信号,确保 goroutine 不会因通道阻塞而泄漏。 +func (s *Service) forwardProviderEvents(ctx context.Context, runID string, sessionID string, input <-chan provider.StreamEvent, done chan<- struct{}) { defer close(done) - for event := range input { - switch event.Type { - case provider.StreamEventTextDelta: - s.emit(EventAgentChunk, runID, sessionID, event.Text) + for { + select { + case event, ok := <-input: + if !ok { + return + } + switch event.Type { + case provider.StreamEventTextDelta: + s.emit(ctx, EventAgentChunk, runID, sessionID, event.Text) + case provider.StreamEventToolCallStart: + s.emit(ctx, EventToolCallThinking, runID, sessionID, event.ToolName) + } + case <-ctx.Done(): + return } } } @@ -296,13 +317,13 @@ func (s *Service) finishRun(token uint64) { s.activeRunCancel = nil } -func (s *Service) handleRunError(runID string, sessionID string, err error) error { +func (s *Service) handleRunError(ctx context.Context, runID string, sessionID string, err error) error { if s.isRunCanceled(err) { - s.emit(EventRunCanceled, runID, sessionID, nil) + s.emit(ctx, EventRunCanceled, runID, sessionID, nil) return context.Canceled } - s.emit(EventError, runID, sessionID, err.Error()) + s.emit(ctx, EventError, runID, sessionID, err.Error()) return err } @@ -346,8 +367,8 @@ func (s *Service) trimMessages(messages []provider.Message) []provider.Message { func (s *Service) systemPrompt() string { return `You are NeoCode, a local coding agent. -Be concise and accurate. -Use tools when necessary. -When a tool fails, inspect the error and continue safely. -Stay within the workspace and avoid destructive behavior unless clearly requested.` + Be concise and accurate. + Use tools when necessary. + When a tool fails, inspect the error and continue safely. + Stay within the workspace and avoid destructive behavior unless clearly requested.` }