Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1d06281
Merge remote-tracking branch 'refs/remotes/upstream/main' into provid…
phantom5099 Apr 8, 2026
55491d3
pref(provider):修改公共契约并增加能力模型
phantom5099 Apr 8, 2026
707049c
fix(provider):修复当前存在EOF被误当作正常完成问题
phantom5099 Apr 8, 2026
ab21ca1
Merge branch 'refs/heads/provider-phase2-A'
phantom5099 Apr 9, 2026
5006a85
Merge remote-tracking branch 'refs/remotes/upstream/main'
phantom5099 Apr 9, 2026
b134397
pref(provider):重构配置装配、远程发现缓存与共享模型类型边界
phantom5099 Apr 9, 2026
d37880a
fix(provider):修复切换到不存在的模型会偷偷改配置等问题
phantom5099 Apr 9, 2026
3319c73
fix(provider):解决冲突
phantom5099 Apr 10, 2026
8588495
Merge remote-tracking branch 'upstream/main'
phantom5099 Apr 10, 2026
1c51367
fix(provider):修 APIStyle 身份不一致
phantom5099 Apr 10, 2026
871a1dd
Merge remote-tracking branch 'upstream/main'
phantom5099 Apr 10, 2026
46bc129
fix(provider):修复测试错误
phantom5099 Apr 10, 2026
56d9be0
docs(session): restore persistence design notes
xgopilot Apr 10, 2026
ca8f094
docs(session): dedupe persistence references
xgopilot Apr 10, 2026
3da60fc
fix(provider):修复强制 current_model 必须非空导致可能启动失败问题
phantom5099 Apr 10, 2026
cd2f541
Merge remote-tracking branch 'upstream/main'
phantom5099 Apr 10, 2026
b7685a7
Merge remote-tracking branch 'origin/main'
phantom5099 Apr 10, 2026
0beb008
Merge remote-tracking branch 'upstream/main'
phantom5099 Apr 10, 2026
8b3f741
pref(provider):增加同步发现模型
phantom5099 Apr 10, 2026
9c1ced6
fix(provider):修复测试报错
phantom5099 Apr 10, 2026
b8dc340
fix(provider):修复测试报错
phantom5099 Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ go run ./cmd/neocode
- **[扩展 Provider](docs/guides/adding-providers.md)** — 如何添加新的模型提供商

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This README now advertises docs/guides/adding-providers.md as the contributor guide, but that guide still describes the pre-refactor API (internal/provider/openai, driver "openai", ChatRequest/Chat). After this PR the canonical names are internal/provider/openaicompat, driver "openaicompat", and GenerateRequest/Generate. Surfacing the stale guide here will send new provider implementations down a non-compiling path.

- **[架构设计](docs/neocode-coding-agent-mvp-architecture.md)** — 整体架构与设计理念
- **[事件流](docs/runtime-provider-event-flow.md)** — Runtime 与 Provider 的事件交互
- **[Session 持久化](docs/session-persistence-design.md)** — 会话 JSON 存储、token 累计与工作区隔离

## 开发

Expand Down
2 changes: 1 addition & 1 deletion docs/context-compact.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ context:

## 自动压缩

当 `auto_compact.enabled` 为 `true` 时,runtime 在每次调用 `context.Builder.Build()` 时将当前 token 累计值传入 Metadata,context 模块通过比较累计值与阈值在 `BuildResult.ShouldAutoCompact` 中返回压缩建议。runtime 读取建议后调用现有 compact 管线执行压缩,并在成功后重置 token 计数器
当 `auto_compact.enabled` 为 `true` 时,runtime 在每次调用 `context.Builder.Build()` 时将当前 token 累计值传入 Metadata,context 模块通过比较累计值与阈值在 `BuildResult.ShouldAutoCompact` 中返回压缩建议。runtime 读取建议后调用现有 compact 管线执行压缩token 计数的重置与持久化语义统一见 [Session 持久化设计](./session-persistence-design.md)

设计原则:
- **context 拥有压缩决策权**,runtime 只做编排执行。
Expand Down
4 changes: 3 additions & 1 deletion docs/runtime-provider-event-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
4. 解析当前 provider 配置并构建 provider 实例。
5. 调用 `context.Builder` 生成本轮请求使用的 `system prompt` 和消息上下文。
6. 如命中 token 阈值自动压缩建议,则先执行一次 compact,再继续构造请求。
7. 调用 `Provider.Chat`,并把流式事件桥接给 TUI。
7. 调用 `Provider.Generate`,并把流式事件桥接给 TUI。
8. 如 provider 返回“上下文过长”错误,则触发一次 `reactive` compact,并仅重试一次当前请求。
9. 保存 assistant 完整回复。
10. 执行返回的工具调用,并保存每一个工具结果。
Expand Down Expand Up @@ -86,3 +86,5 @@ runtime 在转发 provider 流式事件时,从 `MessageDone` 事件中提取 `
- assistant 完整回复后保存
- 每个工具结果完成后保存
- 避免在高频 UI 刷新路径中做磁盘 I/O

会话 JSON 结构、工作区分桶以及 token 计数持久化约束统一见 [Session 持久化设计](./session-persistence-design.md)。
82 changes: 52 additions & 30 deletions docs/session-persistence-design.md
Original file line number Diff line number Diff line change
@@ -1,44 +1,66 @@
# Session 持久化设计

This comment was marked as outdated.


## 模块职责与收口边界
- `internal/session`:承载会话领域模型、存储抽象与 JSON 持久化实现,是唯一的会话持久化实现归属层
- `internal/runtime`:只依赖 `internal/session` 提供的抽象与模型,负责会话保存时机与主循环编排,不再维护会话存储实现细节
- `internal/tui`:仅消费 runtime 暴露的会话数据,不直接执行会话持久化
## 模块职责与边界

- `internal/session` 是会话领域模型、存储抽象与 JSON 持久化实现的唯一归属层。
- `internal/runtime` 负责决定保存时机、恢复会话状态和编排主循环,不承载文件存储细节。
- `internal/tui` 只消费 runtime 暴露的会话数据,不直接读写会话文件。

## 存储策略
NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优先、易于调试和跨平台可移植。

NeoCode 当前使用本地 JSON 文件持久化会话,以保持实现简单、可调试且跨平台可移植。

- 默认目录按工作区隔离:`~/.neocode/projects/<workspace-hash>/sessions/`
- 工作区哈希基于启动阶段确定的工作区根目录生成
- `session.Workdir` 表示会话当前实际执行命令使用的目录,可被运行期命令修改,但不参与分桶
- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读

## 数据模型
- `Session`:完整消息历史以及 `id`、`title`、`updated_at`、`token_input_total`、`token_output_total` 等元信息
- `Summary`:用于侧边栏的轻量摘要结构(原 `SessionSummary` 命名已统一收口为 `Summary`)

### Token 持久化
- `token_input_total` 和 `token_output_total` 分别记录会话累计输入和输出 token。
- 使用 `omitempty` 标签,确保旧版 JSON 文件正常加载(零值不序列化)。
- runtime 在每次 provider 调用后更新 session 的 token 字段,随 session save 一起持久化。
- 会话加载时,runtime 从 session 恢复 token 计数器;新建会话时计数器清零。
- 自动压缩成功后 token 计数器重置为零,并持久化到 session。
`internal/session.Session` 持久化以下核心字段:

- `id`、`title`
- `provider`、`model`
- `created_at`、`updated_at`
- `workdir`
- `messages`
- `token_input_total`
- `token_output_total`

其中:

- `provider` / `model` 记录最近一次成功运行会话时使用的配置,供 compact 等流程优先复用
- `token_input_total` / `token_output_total` 分别表示会话累计输入与输出 token
- token 字段使用 `omitempty`,以兼容旧版 session JSON 文件

## 加载策略
- `ListSummaries` 只读取渲染侧边栏所需的基础信息
- `Load` 仅在用户真正进入某个会话时读取完整消息历史
- `Save` 通过临时文件原子写入完整 Session
`internal/session.Summary` 只保留会话列表渲染所需的轻量字段,不加载完整消息历史。

## 命名策略
- 新会话默认展示为 `New Session`
- 一旦持久化,runtime 会根据首轮用户消息生成简短标题
## 读写行为

- `Save` 使用“临时文件 + 原子替换”写入完整会话 JSON
- `Load` 在用户真正进入某个会话时读取完整历史
- `ListSummaries` 只解析摘要字段,并按 `updated_at` 倒序返回

## Token 计数持久化

- runtime 在 provider 调用完成后更新 session 的累计 token 字段
- 会话保存时,token 计数随 session 一起持久化
- 会话重新加载时,runtime 从 session 恢复累计 token
- 自动 compact 成功后,runtime 会重置累计 token,并将重置后的值持久化

## 保存时机

- 用户消息提交后保存
- assistant 完整回复后保存
- 每个工具结果完成后保存
- 避免在高频 UI 刷新路径中直接做磁盘 I/O

## 并发约束
- `internal/session` 中的 Store 实现必须自行保护共享访问
- 真正的保存时机由 runtime 决定,TUI 不负责直接触发磁盘写入

## 兼容性与演进说明
- 会话持久化能力已从 runtime 侧实现中彻底收口到 `internal/session`
- 新增会话存储实现时,应优先在 `internal/session` 内扩展并通过接口注入 runtime,避免跨层实现
- `internal/session` 的存储实现自行保护共享访问
- 保存时机统一由 runtime 决定,TUI 不直接触发磁盘写入

## 工作区隔离
## 演进约束

- session 现按工作区隔离存储,目录规则为 `~/.neocode/projects/<workspace-hash>/sessions/`
- 工作区哈希基于启动时确定的工作区根目录生成,而不是基于 `session.Workdir`
- `session.Workdir` 仍表示该会话当前实际执行命令时使用的目录,可被 `/cwd` 修改
- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读
- 新增存储实现时,应优先在 `internal/session` 内扩展并通过接口注入
- 不应把持久化逻辑重新分散到 `runtime`、`tui` 或其他上层模块
2 changes: 1 addition & 1 deletion internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,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, providerRegistry, modelCatalogs)
providerSelection := config.NewSelectionService(manager, providerRegistry, modelCatalogs)
if _, err := providerSelection.EnsureSelection(ctx); err != nil {
return RuntimeBundle{}, err
}
Expand Down
45 changes: 45 additions & 0 deletions internal/app/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,51 @@ func TestNewProgramNormalizesInvalidCurrentModelOnStartup(t *testing.T) {
}
}

func TestBuildRuntimeRejectsUnsupportedSelectedProviderDriverOnStartup(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

configDir := filepath.Join(home, ".neocode")
providerDir := filepath.Join(configDir, "providers", "company-gateway")
if err := os.MkdirAll(providerDir, 0o755); err != nil {
t.Fatalf("mkdir provider dir: %v", err)
}

configPath := filepath.Join(configDir, "config.yaml")
rawConfig := []byte("selected_provider: company-gateway\ncurrent_model: claude-3-7-sonnet\nshell: powershell\n")
if err := os.WriteFile(configPath, rawConfig, 0o644); err != nil {
t.Fatalf("write config: %v", err)
}

rawProvider := []byte(
"name: company-gateway\n" +
"driver: anthropic\n" +
"api_key_env: COMPANY_GATEWAY_API_KEY\n" +
"anthropic:\n" +
" base_url: https://api.anthropic.example/v1\n" +
" api_version: 2023-06-01\n",
)
if err := os.WriteFile(filepath.Join(providerDir, "provider.yaml"), rawProvider, 0o644); err != nil {
t.Fatalf("write provider config: %v", err)
}

_, err := BuildRuntime(context.Background(), BootstrapOptions{})
if !errors.Is(err, config.ErrDriverUnsupported) {
t.Fatalf("expected ErrDriverUnsupported, got %v", err)
}

data, readErr := os.ReadFile(configPath)
if readErr != nil {
t.Fatalf("read config: %v", readErr)
}
if !strings.Contains(string(data), "selected_provider: company-gateway") {
t.Fatalf("expected selected_provider to remain unchanged, got:\n%s", string(data))
}
}

func TestBuildToolRegistryUsesWebFetchConfig(t *testing.T) {
t.Parallel()

Expand Down
20 changes: 16 additions & 4 deletions internal/config/builtin_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,49 +20,61 @@ const (
QiniuDefaultBaseURL = "https://api.qnaigc.com/v1"
QiniuDefaultModel = "openai/gpt-5"
QiniuDefaultAPIKeyEnv = "QINIU_API_KEY"

defaultOpenAICompatibleAPIStyle = "chat_completions"
)

const OpenaiCompatDriver = "openaicompat"

// OpenAIProvider returns the builtin OpenAI provider definition.
func OpenAIProvider() ProviderConfig {
return ProviderConfig{
Name: OpenAIName,
Driver: "openai",
Driver: OpenaiCompatDriver,
BaseURL: OpenAIDefaultBaseURL,
Model: OpenAIDefaultModel,
APIKeyEnv: OpenAIDefaultAPIKeyEnv,
APIStyle: defaultOpenAICompatibleAPIStyle,
Source: ProviderSourceBuiltin,
}
}

// GeminiProvider returns the builtin Gemini provider definition.
func GeminiProvider() ProviderConfig {
return ProviderConfig{
Name: GeminiName,
Driver: "openai",
Driver: OpenaiCompatDriver,
BaseURL: GeminiDefaultBaseURL,
Model: GeminiDefaultModel,
APIKeyEnv: GeminiDefaultAPIKeyEnv,
APIStyle: defaultOpenAICompatibleAPIStyle,
Source: ProviderSourceBuiltin,
}
}

// OpenLLProvider returns the builtin OpenLL provider definition.
func OpenLLProvider() ProviderConfig {
return ProviderConfig{
Name: OpenLLName,
Driver: "openai",
Driver: OpenaiCompatDriver,
BaseURL: OpenLLDefaultBaseURL,
Model: OpenLLDefaultModel,
APIKeyEnv: OpenLLDefaultAPIKeyEnv,
APIStyle: defaultOpenAICompatibleAPIStyle,
Source: ProviderSourceBuiltin,
}
}

// QiniuProvider returns the builtin Qiniu provider definition.
func QiniuProvider() ProviderConfig {
return ProviderConfig{
Name: QiniuName,
Driver: "openai",
Driver: OpenaiCompatDriver,
BaseURL: QiniuDefaultBaseURL,
Model: QiniuDefaultModel,
APIKeyEnv: QiniuDefaultAPIKeyEnv,
APIStyle: defaultOpenAICompatibleAPIStyle,
Source: ProviderSourceBuiltin,
}
}

Expand Down
12 changes: 6 additions & 6 deletions internal/config/builtin_providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ func TestOpenAIProviderConfig(t *testing.T) {
if provider.Name != OpenAIName {
t.Fatalf("expected name %q, got %q", OpenAIName, provider.Name)
}
if provider.Driver != "openai" {
t.Fatalf("expected driver %q, got %q", "openai", provider.Driver)
if provider.Driver != "openaicompat" {
t.Fatalf("expected driver %q, got %q", "openaicompat", provider.Driver)
}
if provider.BaseURL != OpenAIDefaultBaseURL {
t.Fatalf("expected base URL %q, got %q", OpenAIDefaultBaseURL, provider.BaseURL)
Expand All @@ -50,8 +50,8 @@ func TestGeminiProviderConfig(t *testing.T) {
if provider.Name != GeminiName {
t.Fatalf("expected name %q, got %q", GeminiName, provider.Name)
}
if provider.Driver != "openai" {
t.Fatalf("expected driver %q, got %q", "openai", provider.Driver)
if provider.Driver != "openaicompat" {
t.Fatalf("expected driver %q, got %q", "openaicompat", provider.Driver)
}
if provider.BaseURL != GeminiDefaultBaseURL {
t.Fatalf("expected base URL %q, got %q", GeminiDefaultBaseURL, provider.BaseURL)
Expand All @@ -72,8 +72,8 @@ func TestOpenLLProviderConfig(t *testing.T) {
if provider.Name != OpenLLName {
t.Fatalf("expected name %q, got %q", OpenLLName, provider.Name)
}
if provider.Driver != "openai" {
t.Fatalf("expected driver %q, got %q", "openai", provider.Driver)
if provider.Driver != "openaicompat" {
t.Fatalf("expected driver %q, got %q", "openaicompat", provider.Driver)
}
if provider.BaseURL != OpenLLDefaultBaseURL {
t.Fatalf("expected base URL %q, got %q", OpenLLDefaultBaseURL, provider.BaseURL)
Expand Down
Loading
Loading