From f40048edded305ac7162190edac7ba5f45a3f03c Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 7 Apr 2026 14:41:26 +0800 Subject: [PATCH 1/8] =?UTF-8?q?refactor(provider):=E6=8B=86=E5=88=86type?= =?UTF-8?q?=E5=92=8Copenai?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/session-persistence-design.md | 20 - docs/tool-execution-toctou.md | 50 - internal/context/builder.go | 4 +- internal/context/builder_test.go | 226 +-- internal/context/compact/helpers.go | 10 +- internal/context/compact/planner.go | 14 +- internal/context/compact/planner_test.go | 42 +- internal/context/compact/runner.go | 24 +- internal/context/compact/runner_test.go | 184 +-- internal/context/compact/transcript_store.go | 20 +- .../context/compact/transcript_store_test.go | 8 +- internal/context/compact_prompt.go | 10 +- internal/context/compact_prompt_test.go | 14 +- internal/context/internalcompact/messages.go | 12 +- .../context/internalcompact/messages_test.go | 18 +- internal/context/microcompact.go | 24 +- internal/context/microcompact_test.go | 186 +-- internal/context/trim.go | 8 +- internal/context/trim_policy.go | 8 +- internal/context/types.go | 6 +- internal/provider/catalog/service_test.go | 3 +- internal/provider/discovery/discovery.go | 50 - internal/provider/discovery/discovery_test.go | 51 - internal/provider/openai/discovery.go | 51 + internal/provider/openai/driver.go | 35 + internal/provider/openai/events.go | 63 + internal/provider/openai/openai.go | 501 ------ internal/provider/openai/openai_test.go | 1385 +++++++++++------ internal/provider/openai/provider.go | 121 ++ internal/provider/openai/request.go | 105 ++ internal/provider/openai/response.go | 135 ++ internal/provider/openai/toolcall.go | 43 + internal/provider/openai/types.go | 90 ++ internal/provider/provider.go | 9 +- internal/provider/registry_test.go | 3 +- .../provider/{types.go => types/event.go} | 51 +- internal/provider/types/message.go | 36 + internal/provider/types/request.go | 16 + internal/provider/{ => types}/types_test.go | 2 +- internal/runtime/compact.go | 6 +- internal/runtime/compact_generator.go | 10 +- internal/runtime/compact_generator_test.go | 40 +- internal/runtime/permission.go | 6 +- internal/runtime/permission_test.go | 22 +- internal/runtime/runtime.go | 51 +- internal/runtime/runtime_test.go | 327 ++-- internal/runtime/session.go | 14 +- internal/runtime/session_test.go | 8 +- internal/tools/manager.go | 8 +- internal/tools/manager_test.go | 4 +- internal/tools/registry.go | 12 +- internal/tools/types.go | 4 +- internal/tui/app.go | 4 +- internal/tui/copy_code_test.go | 10 +- internal/tui/update.go | 16 +- internal/tui/update_test.go | 21 +- internal/tui/view.go | 4 +- 57 files changed, 2284 insertions(+), 1921 deletions(-) delete mode 100644 docs/session-persistence-design.md delete mode 100644 docs/tool-execution-toctou.md delete mode 100644 internal/provider/discovery/discovery.go delete mode 100644 internal/provider/discovery/discovery_test.go create mode 100644 internal/provider/openai/discovery.go create mode 100644 internal/provider/openai/driver.go create mode 100644 internal/provider/openai/events.go delete mode 100644 internal/provider/openai/openai.go create mode 100644 internal/provider/openai/provider.go create mode 100644 internal/provider/openai/request.go create mode 100644 internal/provider/openai/response.go create mode 100644 internal/provider/openai/toolcall.go create mode 100644 internal/provider/openai/types.go rename internal/provider/{types.go => types/event.go} (76%) create mode 100644 internal/provider/types/message.go create mode 100644 internal/provider/types/request.go rename internal/provider/{ => types}/types_test.go (99%) diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md deleted file mode 100644 index 0c5d1887..00000000 --- a/docs/session-persistence-design.md +++ /dev/null @@ -1,20 +0,0 @@ -# Session 持久化设计 -## 存储策略 -NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优先、易于调试和跨平台可移植。 - -## 数据模型 -- `Session`:完整消息历史以及 `id`、`title`、`updated_at` 等元信息 -- `SessionSummary`:用于侧边栏的轻量摘要结构 - -## 加载策略 -- `ListSummaries` 只读取渲染侧边栏所需的基础信息 -- `Load` 仅在用户真正进入某个会话时读取完整消息历史 -- `Save` 通过临时文件原子写入完整 Session - -## 命名策略 -- 新会话默认展示为 `Draft` -- 一旦持久化,runtime 会根据首轮用户消息生成简短标题 - -## 并发约束 -- SessionStore 实现必须自行保护共享访问 -- 真正的保存时机由 runtime 决定,TUI 不负责直接触发磁盘写入 diff --git a/docs/tool-execution-toctou.md b/docs/tool-execution-toctou.md deleted file mode 100644 index f4ce8347..00000000 --- a/docs/tool-execution-toctou.md +++ /dev/null @@ -1,50 +0,0 @@ -# 工具执行期 TOCTOU 防护设计 - -## 背景 -`WorkspaceSandbox` 在此前版本主要完成“路径边界校验”,执行链为: - -`Permission + Sandbox Check -> Tool.Execute(path string)` - -这会留下检查与执行之间的 TOCTOU(Time-of-Check to Time-of-Use)窗口:路径在校验后被替换,工具仍可能访问到非预期对象。 - -## 本次实现 -本次将链路升级为: - -`Permission + Sandbox Check -> WorkspaceExecutionPlan -> Tool.Execute(plan + args)` - -核心变化如下: - -1. `WorkspaceSandbox.Check` 不再只返回 `error`,而是返回 `*WorkspaceExecutionPlan`。 -2. `ToolManager` 将 plan 透传到 `ToolCallInput.WorkspacePlan`。 -3. `filesystem_read_file` / `filesystem_write_file` / `filesystem_edit` / `bash` 在真实执行前调用 `plan.ValidateForExecution()` 复验锚点状态。 -4. 工具使用 `tools.ResolveWorkspaceTarget` 统一消费 plan,避免再次仅依赖字符串路径解析。 - -## 执行期绑定机制 -`WorkspaceExecutionPlan` 在 sandbox 阶段记录: - -- 规范化后的 workspace root -- 规范化后的最终 target -- 最近存在路径锚点(anchor) -- 锚点快照(模式、大小、修改时间、符号链接目标) - -工具执行前会复验: - -1. 当前锚点是否仍为同一路径; -2. 锚点快照是否与校验阶段一致; -3. 锚点解析后的真实路径是否仍在 workspace 内。 - -若任一步失败,返回稳定错误:`workspace target changed before execution` 或 `escapes workspace root via symlink`。 - -## 当前防护边界 -已覆盖: - -- 校验后 symlink 被替换导致 read 越界 -- 校验后父目录被替换导致 write 越界 -- 校验后 bash workdir 被替换导致 cwd 漂移 - -仍存在限制(已显式记录): - -- 未实现跨平台 `openat/no-follow/dirfd` 级别的系统调用原子封装 -- 仍非容器级隔离,不替代系统沙箱 - -本次目标是把“校验结果”显式带入执行期,显著缩小 TOCTOU 窗口,并为后续更强执行器(含 MCP)预留统一接口。 diff --git a/internal/context/builder.go b/internal/context/builder.go index db2e77c1..96e3c437 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -3,7 +3,7 @@ package context import ( "context" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) // DefaultBuilder preserves the current runtime context-building behavior. @@ -59,7 +59,7 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu } // applyReadTimeContextProjection 负责在 provider 请求前按开关应用只读上下文投影,避免改写原始会话消息。 -func applyReadTimeContextProjection(messages []provider.Message, options CompactOptions, policies MicroCompactPolicySource) []provider.Message { +func applyReadTimeContextProjection(messages []providertypes.Message, options CompactOptions, policies MicroCompactPolicySource) []providertypes.Message { if options.DisableMicroCompact { return cloneContextMessages(messages) } diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index e76d4389..dfe2b838 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -10,7 +10,7 @@ import ( "testing" "neo-code/internal/context/internalcompact" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" "neo-code/internal/tools" ) @@ -31,7 +31,7 @@ func TestDefaultBuilderBuild(t *testing.T) { builder := NewBuilder() input := BuildInput{ - Messages: []provider.Message{ + Messages: []providertypes.Message{ {Role: "user", Content: "hello"}, }, Metadata: testMetadata(t.TempDir()), @@ -90,7 +90,7 @@ func TestDefaultBuilderBuildComposesPromptSectionsInOrder(t *testing.T) { builder := NewBuilder() got, err := builder.Build(stdcontext.Background(), BuildInput{ - Messages: []provider.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, Metadata: testMetadata(root), }) if err != nil { @@ -111,10 +111,10 @@ func TestDefaultBuilderBuildComposesPromptSectionsInOrder(t *testing.T) { func TestDefaultBuilderBuildUsesSpanTrimPolicyWhenTrimPolicyIsUnset(t *testing.T) { t.Parallel() - messages := make([]provider.Message, 0, maxRetainedMessageSpans+2) + messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+2) for i := 0; i < maxRetainedMessageSpans+2; i++ { - messages = append(messages, provider.Message{ - Role: provider.RoleUser, + messages = append(messages, providertypes.Message{ + Role: providertypes.RoleUser, Content: fmt.Sprintf("u-%d", i), }) } @@ -161,31 +161,31 @@ func TestDefaultBuilderBuildAppliesMicroCompactAfterTrim(t *testing.T) { }, } - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "older user"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old read result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old read result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, Content: "current reply"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "current reply"}, } got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages}) @@ -215,31 +215,31 @@ func TestDefaultBuilderBuildSkipsMicroCompactWhenDisabled(t *testing.T) { }, } - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "older user"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old read result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old read result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, Content: "current reply"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "current reply"}, } got, err := builder.Build(stdcontext.Background(), BuildInput{ @@ -271,30 +271,30 @@ func TestDefaultBuilderBuildHonorsToolMicroCompactPolicies(t *testing.T) { }, } - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "older user"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "custom_tool", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old custom result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old custom result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, } got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages}) @@ -313,30 +313,30 @@ func TestNewBuilderWithToolPoliciesUsesProvidedPolicySource(t *testing.T) { "custom_tool": tools.MicroCompactPolicyPreserveHistory, }) - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "older user"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "custom_tool", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old custom result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old custom result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, } got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages}) @@ -351,20 +351,20 @@ func TestNewBuilderWithToolPoliciesUsesProvidedPolicySource(t *testing.T) { func TestTrimMessagesPreservesToolPairs(t *testing.T) { t.Parallel() - messages := make([]provider.Message, 0, maxRetainedMessageSpans+4) + messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+4) for i := 0; i < 8; i++ { - messages = append(messages, provider.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) + messages = append(messages, providertypes.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) } messages = append(messages, - provider.Message{ + providertypes.Message{ Role: "assistant", - ToolCalls: []provider.ToolCall{ + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_edit", Arguments: "{}"}, }, }, - provider.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-result"}, - provider.Message{Role: "assistant", Content: "after-tool"}, - provider.Message{Role: "user", Content: "latest"}, + providertypes.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-result"}, + providertypes.Message{Role: "assistant", Content: "after-tool"}, + providertypes.Message{Role: "user", Content: "latest"}, ) trimmed := trimMessages(messages) @@ -390,27 +390,27 @@ func TestTrimMessagesPreservesToolPairs(t *testing.T) { func TestTrimMessagesProtectsLatestExplicitUserInstructionTail(t *testing.T) { t.Parallel() - messages := make([]provider.Message, 0, maxRetainedMessageSpans+5) + messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+5) for i := 0; i < 2; i++ { - messages = append(messages, provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("old-%d", i)}) + messages = append(messages, providertypes.Message{Role: providertypes.RoleUser, Content: fmt.Sprintf("old-%d", i)}) } messages = append(messages, - provider.Message{Role: provider.RoleUser, Content: "latest explicit instruction"}, - provider.Message{Role: provider.RoleAssistant, Content: "follow-up-1"}, - provider.Message{Role: provider.RoleAssistant, Content: "follow-up-2"}, - provider.Message{Role: provider.RoleAssistant, Content: "follow-up-3"}, - provider.Message{Role: provider.RoleAssistant, Content: "follow-up-4"}, - provider.Message{Role: provider.RoleAssistant, Content: "follow-up-5"}, - provider.Message{Role: provider.RoleAssistant, Content: "follow-up-6"}, - provider.Message{Role: provider.RoleAssistant, Content: "follow-up-7"}, - provider.Message{Role: provider.RoleAssistant, Content: "follow-up-8"}, - provider.Message{Role: provider.RoleAssistant, Content: "follow-up-9"}, - provider.Message{Role: provider.RoleAssistant, Content: "follow-up-10"}, - provider.Message{Role: provider.RoleAssistant, Content: "follow-up-11"}, + providertypes.Message{Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-1"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-2"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-3"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-4"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-5"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-6"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-7"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-8"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-9"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-10"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-11"}, ) trimmed := trimMessages(messages) - if trimmed[0].Role != provider.RoleUser || trimmed[0].Content != "latest explicit instruction" { + if trimmed[0].Role != providertypes.RoleUser || trimmed[0].Content != "latest explicit instruction" { t.Fatalf("expected protected tail to keep latest explicit user instruction, got %+v", trimmed[0]) } if len(trimmed) != 12 { @@ -421,27 +421,27 @@ func TestTrimMessagesProtectsLatestExplicitUserInstructionTail(t *testing.T) { func TestTrimMessagesUsesSharedSpanModel(t *testing.T) { t.Parallel() - messages := make([]provider.Message, 0, maxRetainedMessageSpans+6) + messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+6) for i := 0; i < 3; i++ { - messages = append(messages, provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("u-%d", i)}) + messages = append(messages, providertypes.Message{Role: providertypes.RoleUser, Content: fmt.Sprintf("u-%d", i)}) } messages = append(messages, - provider.Message{ - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + providertypes.Message{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - provider.Message{Role: provider.RoleTool, ToolCallID: "call-2", Content: "tool-result"}, - provider.Message{Role: provider.RoleAssistant, Content: "after tool"}, - provider.Message{Role: provider.RoleUser, Content: "u-4"}, - provider.Message{Role: provider.RoleAssistant, Content: "a-5"}, - provider.Message{Role: provider.RoleUser, Content: "u-6"}, - provider.Message{Role: provider.RoleAssistant, Content: "a-7"}, - provider.Message{Role: provider.RoleUser, Content: "u-8"}, - provider.Message{Role: provider.RoleAssistant, Content: "a-9"}, - provider.Message{Role: provider.RoleUser, Content: "u-10"}, - provider.Message{Role: provider.RoleAssistant, Content: "a-11"}, + providertypes.Message{Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "tool-result"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "after tool"}, + providertypes.Message{Role: providertypes.RoleUser, Content: "u-4"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "a-5"}, + providertypes.Message{Role: providertypes.RoleUser, Content: "u-6"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "a-7"}, + providertypes.Message{Role: providertypes.RoleUser, Content: "u-8"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "a-9"}, + providertypes.Message{Role: providertypes.RoleUser, Content: "u-10"}, + providertypes.Message{Role: providertypes.RoleAssistant, Content: "a-11"}, ) spans := internalcompact.BuildMessageSpans(messages) @@ -451,7 +451,7 @@ func TestTrimMessagesUsesSharedSpanModel(t *testing.T) { if len(trimmed) == 0 || trimmed[0].Content != messages[start].Content { t.Fatalf("expected trim to start from shared span boundary %d, got %+v", start, trimmed) } - if trimmed[0].Role != provider.RoleAssistant || len(trimmed[0].ToolCalls) != 1 { + if trimmed[0].Role != providertypes.RoleAssistant || len(trimmed[0].ToolCalls) != 1 { t.Fatalf("expected trim to keep whole tool block at shared boundary, got %+v", trimmed[0]) } } @@ -461,18 +461,18 @@ func TestTrimMessagesBoundaries(t *testing.T) { tests := []struct { name string - input []provider.Message + input []providertypes.Message wantLen int - assert func(t *testing.T, original []provider.Message, trimmed []provider.Message) + assert func(t *testing.T, original []providertypes.Message, trimmed []providertypes.Message) }{ { name: "within max turns returns full cloned slice", - input: []provider.Message{ + input: []providertypes.Message{ {Role: "user", Content: "one"}, {Role: "assistant", Content: "two"}, }, wantLen: 2, - assert: func(t *testing.T, original []provider.Message, trimmed []provider.Message) { + assert: func(t *testing.T, original []providertypes.Message, trimmed []providertypes.Message) { t.Helper() if &trimmed[0] == &original[0] { t.Fatalf("expected trimmed slice to be cloned") @@ -481,25 +481,25 @@ func TestTrimMessagesBoundaries(t *testing.T) { }, { name: "long message list with limited spans keeps full history", - input: func() []provider.Message { - messages := make([]provider.Message, 0, maxRetainedMessageSpans+3) + input: func() []providertypes.Message { + messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+3) for i := 0; i < maxRetainedMessageSpans-1; i++ { - messages = append(messages, provider.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) + messages = append(messages, providertypes.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) } messages = append(messages, - provider.Message{ + providertypes.Message{ Role: "assistant", - ToolCalls: []provider.ToolCall{ + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_edit", Arguments: "{}"}, }, }, - provider.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-1"}, - provider.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-2"}, + providertypes.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-1"}, + providertypes.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-2"}, ) return messages }(), wantLen: maxRetainedMessageSpans + 2, - assert: func(t *testing.T, original []provider.Message, trimmed []provider.Message) { + assert: func(t *testing.T, original []providertypes.Message, trimmed []providertypes.Message) { t.Helper() if len(trimmed) != len(original) { t.Fatalf("expected full history to remain, got %d want %d", len(trimmed), len(original)) @@ -508,24 +508,24 @@ func TestTrimMessagesBoundaries(t *testing.T) { }, { name: "message count beyond limit trims by span count", - input: func() []provider.Message { - messages := make([]provider.Message, 0, maxRetainedMessageSpans+5) + input: func() []providertypes.Message { + messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+5) for i := 0; i < maxRetainedMessageSpans+1; i++ { - messages = append(messages, provider.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) + messages = append(messages, providertypes.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) } messages = append(messages, - provider.Message{ + providertypes.Message{ Role: "assistant", - ToolCalls: []provider.ToolCall{ + ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "filesystem_edit", Arguments: "{}"}, }, }, - provider.Message{Role: "tool", ToolCallID: "call-2", Content: "tool-result"}, + providertypes.Message{Role: "tool", ToolCallID: "call-2", Content: "tool-result"}, ) return messages }(), wantLen: maxRetainedMessageSpans + 1, - assert: func(t *testing.T, original []provider.Message, trimmed []provider.Message) { + assert: func(t *testing.T, original []providertypes.Message, trimmed []providertypes.Message) { t.Helper() if trimmed[0].Content != "u-2" { t.Fatalf("expected oldest spans to be removed, got first message %+v", trimmed[0]) diff --git a/internal/context/compact/helpers.go b/internal/context/compact/helpers.go index edbd8420..a66ce845 100644 --- a/internal/context/compact/helpers.go +++ b/internal/context/compact/helpers.go @@ -3,25 +3,25 @@ package compact import ( "unicode/utf8" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) // cloneMessages 深拷贝消息切片,避免后续规划或摘要阶段共享底层数据。 -func cloneMessages(messages []provider.Message) []provider.Message { +func cloneMessages(messages []providertypes.Message) []providertypes.Message { if len(messages) == 0 { return nil } - out := make([]provider.Message, 0, len(messages)) + out := make([]providertypes.Message, 0, len(messages)) for _, message := range messages { next := message - next.ToolCalls = append([]provider.ToolCall(nil), message.ToolCalls...) + next.ToolCalls = append([]providertypes.ToolCall(nil), message.ToolCalls...) out = append(out, next) } return out } // countMessageChars 以 rune 数量统计消息体积,用于 compact 前后指标计算。 -func countMessageChars(messages []provider.Message) int { +func countMessageChars(messages []providertypes.Message) int { total := 0 for _, message := range messages { total += utf8.RuneCountInString(message.Role) diff --git a/internal/context/compact/planner.go b/internal/context/compact/planner.go index 7f06c043..7ecb2204 100644 --- a/internal/context/compact/planner.go +++ b/internal/context/compact/planner.go @@ -6,13 +6,13 @@ import ( "neo-code/internal/config" "neo-code/internal/context/internalcompact" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) // compactionPlan 描述一次 compact 在摘要生成前的归档与保留结果。 type compactionPlan struct { - Archived []provider.Message - Retained []provider.Message + Archived []providertypes.Message + Retained []providertypes.Message ArchivedMessageCount int Applied bool } @@ -21,7 +21,7 @@ type compactionPlan struct { type compactionPlanner struct{} // Plan 根据 mode 与配置返回摘要前的裁剪规划结果。 -func (compactionPlanner) Plan(mode Mode, messages []provider.Message, cfg config.CompactConfig) (compactionPlan, error) { +func (compactionPlanner) Plan(mode Mode, messages []providertypes.Message, cfg config.CompactConfig) (compactionPlan, error) { if mode == ModeReactive { return planKeepRecent(messages, cfg.ManualKeepRecentMessages), nil } @@ -37,7 +37,7 @@ func (compactionPlanner) Plan(mode Mode, messages []provider.Message, cfg config } // planKeepRecent 计算 keep_recent 策略下需要摘要与保留的消息集合。 -func planKeepRecent(messages []provider.Message, keepMessages int) compactionPlan { +func planKeepRecent(messages []providertypes.Message, keepMessages int) compactionPlan { spans := internalcompact.BuildMessageSpans(messages) retainedStart := internalcompact.RetainedStartForKeepRecentMessages(spans, keepMessages) if retainedStart <= 0 { @@ -57,7 +57,7 @@ func planKeepRecent(messages []provider.Message, keepMessages int) compactionPla } // planFullReplace 计算 full_replace 策略下需要摘要与保留的消息集合。 -func planFullReplace(messages []provider.Message) compactionPlan { +func planFullReplace(messages []providertypes.Message) compactionPlan { if len(messages) == 0 { return compactionPlan{} } @@ -78,7 +78,7 @@ func planFullReplace(messages []provider.Message) compactionPlan { } // splitMessagesAt 按 retained 起点切分 archived 与 retained,并返回深拷贝结果。 -func splitMessagesAt(messages []provider.Message, retainedStart int) ([]provider.Message, []provider.Message) { +func splitMessagesAt(messages []providertypes.Message, retainedStart int) ([]providertypes.Message, []providertypes.Message) { if retainedStart <= 0 { return nil, cloneMessages(messages) } diff --git a/internal/context/compact/planner_test.go b/internal/context/compact/planner_test.go index be243445..bcce24fd 100644 --- a/internal/context/compact/planner_test.go +++ b/internal/context/compact/planner_test.go @@ -4,20 +4,20 @@ import ( "testing" "neo-code/internal/config" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) func TestCompactionPlannerKeepRecentPlan(t *testing.T) { t.Parallel() planner := compactionPlanner{} - plan, err := planner.Plan(ModeManual, []provider.Message{ - {Role: provider.RoleUser, Content: "old request"}, - {Role: provider.RoleAssistant, Content: "old answer"}, - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}}}, - {Role: provider.RoleTool, ToolCallID: "call-1", Content: "tool result"}, - {Role: provider.RoleUser, Content: "latest instruction"}, - {Role: provider.RoleAssistant, Content: "latest answer"}, + plan, err := planner.Plan(ModeManual, []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "old request"}, + {Role: providertypes.RoleAssistant, Content: "old answer"}, + {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}}}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "tool result"}, + {Role: providertypes.RoleUser, Content: "latest instruction"}, + {Role: providertypes.RoleAssistant, Content: "latest answer"}, }, config.CompactConfig{ ManualStrategy: config.CompactManualStrategyKeepRecent, ManualKeepRecentMessages: 3, @@ -31,10 +31,10 @@ func TestCompactionPlannerKeepRecentPlan(t *testing.T) { if len(plan.Archived) != 2 || len(plan.Retained) != 4 { t.Fatalf("unexpected keep_recent plan: %+v", plan) } - if plan.Retained[0].Role != provider.RoleAssistant || len(plan.Retained[0].ToolCalls) != 1 { + if plan.Retained[0].Role != providertypes.RoleAssistant || len(plan.Retained[0].ToolCalls) != 1 { t.Fatalf("expected retained tool block start, got %+v", plan.Retained[0]) } - if plan.Retained[1].Role != provider.RoleTool { + if plan.Retained[1].Role != providertypes.RoleTool { t.Fatalf("expected retained tool result, got %+v", plan.Retained[1]) } } @@ -43,11 +43,11 @@ func TestCompactionPlannerFullReplaceProtectsLatestExplicitUserInstruction(t *te t.Parallel() planner := compactionPlanner{} - plan, err := planner.Plan(ModeManual, []provider.Message{ - {Role: provider.RoleUser, Content: "old request"}, - {Role: provider.RoleAssistant, Content: "old answer"}, - {Role: provider.RoleUser, Content: "latest instruction"}, - {Role: provider.RoleAssistant, Content: "latest answer"}, + plan, err := planner.Plan(ModeManual, []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "old request"}, + {Role: providertypes.RoleAssistant, Content: "old answer"}, + {Role: providertypes.RoleUser, Content: "latest instruction"}, + {Role: providertypes.RoleAssistant, Content: "latest answer"}, }, config.CompactConfig{ ManualStrategy: config.CompactManualStrategyFullReplace, }) @@ -60,7 +60,7 @@ func TestCompactionPlannerFullReplaceProtectsLatestExplicitUserInstruction(t *te if len(plan.Archived) != 2 || len(plan.Retained) != 2 { t.Fatalf("unexpected full_replace plan: %+v", plan) } - if plan.Retained[0].Role != provider.RoleUser || plan.Retained[0].Content != "latest instruction" { + if plan.Retained[0].Role != providertypes.RoleUser || plan.Retained[0].Content != "latest instruction" { t.Fatalf("expected latest explicit user instruction to stay retained, got %+v", plan.Retained) } } @@ -77,11 +77,11 @@ func TestCompactionPlannerRejectsUnsupportedStrategy(t *testing.T) { func TestCompactionPlannerReactiveModeAlwaysUsesKeepRecentStrategy(t *testing.T) { t.Parallel() - plan, err := (compactionPlanner{}).Plan(ModeReactive, []provider.Message{ - {Role: provider.RoleUser, Content: "old request"}, - {Role: provider.RoleAssistant, Content: "old answer"}, - {Role: provider.RoleUser, Content: "latest request"}, - {Role: provider.RoleAssistant, Content: "latest answer"}, + plan, err := (compactionPlanner{}).Plan(ModeReactive, []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "old request"}, + {Role: providertypes.RoleAssistant, Content: "old answer"}, + {Role: providertypes.RoleUser, Content: "latest request"}, + {Role: providertypes.RoleAssistant, Content: "latest answer"}, }, config.CompactConfig{ ManualStrategy: "unsupported", ManualKeepRecentMessages: 2, diff --git a/internal/context/compact/runner.go b/internal/context/compact/runner.go index 1896c03b..6ae8dc9c 100644 --- a/internal/context/compact/runner.go +++ b/internal/context/compact/runner.go @@ -9,7 +9,7 @@ import ( "time" "neo-code/internal/config" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) // Mode identifies the compact execution mode. @@ -34,15 +34,15 @@ type Input struct { Mode Mode SessionID string Workdir string - Messages []provider.Message + Messages []providertypes.Message Config config.CompactConfig } // SummaryInput describes the historical context that must be summarized. type SummaryInput struct { Mode Mode - ArchivedMessages []provider.Message - RetainedMessages []provider.Message + ArchivedMessages []providertypes.Message + RetainedMessages []providertypes.Message ArchivedMessageCount int Config config.CompactConfig } @@ -57,12 +57,12 @@ type Metrics struct { // Result is the compact execution result. type Result struct { - Messages []provider.Message `json:"messages"` - Metrics Metrics `json:"metrics"` - TranscriptID string `json:"transcript_id"` - TranscriptPath string `json:"transcript_path"` - Applied bool `json:"applied"` - ErrorMode ErrorMode `json:"error_mode"` + Messages []providertypes.Message `json:"messages"` + Metrics Metrics `json:"metrics"` + TranscriptID string `json:"transcript_id"` + TranscriptPath string `json:"transcript_path"` + Applied bool `json:"applied"` + ErrorMode ErrorMode `json:"error_mode"` } // SummaryGenerator produces the semantic compact summary. @@ -155,8 +155,8 @@ func (s *Service) Run(ctx context.Context, input Input) (Result, error) { return Result{}, err } - next := make([]provider.Message, 0, len(plan.Retained)+1) - next = append(next, provider.Message{Role: provider.RoleAssistant, Content: summary}) + next := make([]providertypes.Message, 0, len(plan.Retained)+1) + next = append(next, providertypes.Message{Role: providertypes.RoleAssistant, Content: summary}) next = append(next, plan.Retained...) afterChars := countMessageChars(next) diff --git a/internal/context/compact/runner_test.go b/internal/context/compact/runner_test.go index ccf92451..4ab50825 100644 --- a/internal/context/compact/runner_test.go +++ b/internal/context/compact/runner_test.go @@ -11,7 +11,7 @@ import ( "neo-code/internal/config" "neo-code/internal/context/internalcompact" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) type stubSummaryGenerator struct { @@ -56,19 +56,19 @@ func TestManualCompactKeepRecentRetainsRecentMessagesAndWholeToolBlock(t *testin home := t.TempDir() runner.userHomeDir = func() (string, error) { return home, nil } - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "old requirement"}, - {Role: provider.RoleAssistant, Content: "old answer"}, - {Role: provider.RoleUser, Content: "middle request"}, - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-old", Name: "filesystem_grep", Arguments: "{}"}}}, - {Role: provider.RoleTool, ToolCallID: "call-old", Content: "old result"}, - {Role: provider.RoleAssistant, Content: "after tool"}, - {Role: provider.RoleUser, Content: "instruction to keep"}, - {Role: provider.RoleAssistant, Content: "ack"}, - {Role: provider.RoleUser, Content: "recent follow up"}, - {Role: provider.RoleAssistant, Content: "recent answer"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, Content: "latest result"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "old requirement"}, + {Role: providertypes.RoleAssistant, Content: "old answer"}, + {Role: providertypes.RoleUser, Content: "middle request"}, + {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "call-old", Name: "filesystem_grep", Arguments: "{}"}}}, + {Role: providertypes.RoleTool, ToolCallID: "call-old", Content: "old result"}, + {Role: providertypes.RoleAssistant, Content: "after tool"}, + {Role: providertypes.RoleUser, Content: "instruction to keep"}, + {Role: providertypes.RoleAssistant, Content: "ack"}, + {Role: providertypes.RoleUser, Content: "recent follow up"}, + {Role: providertypes.RoleAssistant, Content: "recent answer"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "latest result"}, } result, err := runner.Run(context.Background(), Input{ @@ -91,7 +91,7 @@ func TestManualCompactKeepRecentRetainsRecentMessagesAndWholeToolBlock(t *testin if len(result.Messages) != 10 { t.Fatalf("expected summary + 9 retained messages, got %d", len(result.Messages)) } - if result.Messages[0].Role != provider.RoleAssistant { + if result.Messages[0].Role != providertypes.RoleAssistant { t.Fatalf("expected summary role assistant, got %q", result.Messages[0].Role) } for _, section := range []string{"done:", "in_progress:", "decisions:", "code_changes:", "constraints:"} { @@ -99,10 +99,10 @@ func TestManualCompactKeepRecentRetainsRecentMessagesAndWholeToolBlock(t *testin t.Fatalf("expected summary to include section %q, got %q", section, result.Messages[0].Content) } } - if result.Messages[1].Role != provider.RoleAssistant || len(result.Messages[1].ToolCalls) != 1 { + if result.Messages[1].Role != providertypes.RoleAssistant || len(result.Messages[1].ToolCalls) != 1 { t.Fatalf("expected retained tool call block start, got %+v", result.Messages[1]) } - if result.Messages[2].Role != provider.RoleTool || result.Messages[2].ToolCallID != "call-old" { + if result.Messages[2].Role != providertypes.RoleTool || result.Messages[2].ToolCallID != "call-old" { t.Fatalf("expected retained tool result, got %+v", result.Messages[2]) } if len(generator.calls) != 1 { @@ -124,19 +124,19 @@ func TestReactiveCompactUsesKeepRecentAndReportsReactiveMode(t *testing.T) { home := t.TempDir() runner.userHomeDir = func() (string, error) { return home, nil } - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "old requirement"}, - {Role: provider.RoleAssistant, Content: "old answer"}, - {Role: provider.RoleUser, Content: "middle request"}, - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-old", Name: "filesystem_grep", Arguments: "{}"}}}, - {Role: provider.RoleTool, ToolCallID: "call-old", Content: "old result"}, - {Role: provider.RoleAssistant, Content: "after tool"}, - {Role: provider.RoleUser, Content: "instruction to keep"}, - {Role: provider.RoleAssistant, Content: "ack"}, - {Role: provider.RoleUser, Content: "recent follow up"}, - {Role: provider.RoleAssistant, Content: "recent answer"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, Content: "latest result"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "old requirement"}, + {Role: providertypes.RoleAssistant, Content: "old answer"}, + {Role: providertypes.RoleUser, Content: "middle request"}, + {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "call-old", Name: "filesystem_grep", Arguments: "{}"}}}, + {Role: providertypes.RoleTool, ToolCallID: "call-old", Content: "old result"}, + {Role: providertypes.RoleAssistant, Content: "after tool"}, + {Role: providertypes.RoleUser, Content: "instruction to keep"}, + {Role: providertypes.RoleAssistant, Content: "ack"}, + {Role: providertypes.RoleUser, Content: "recent follow up"}, + {Role: providertypes.RoleAssistant, Content: "recent answer"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "latest result"}, } result, err := runner.Run(context.Background(), Input{ @@ -165,10 +165,10 @@ func TestReactiveCompactUsesKeepRecentAndReportsReactiveMode(t *testing.T) { if len(result.Messages) != 10 { t.Fatalf("expected summary + 9 retained messages, got %d", len(result.Messages)) } - if result.Messages[1].Role != provider.RoleAssistant || len(result.Messages[1].ToolCalls) != 1 { + if result.Messages[1].Role != providertypes.RoleAssistant || len(result.Messages[1].ToolCalls) != 1 { t.Fatalf("expected retained tool call block start, got %+v", result.Messages[1]) } - if result.Messages[2].Role != provider.RoleTool || result.Messages[2].ToolCallID != "call-old" { + if result.Messages[2].Role != providertypes.RoleTool || result.Messages[2].ToolCallID != "call-old" { t.Fatalf("expected retained tool result, got %+v", result.Messages[2]) } if len(generator.calls) != 1 { @@ -192,16 +192,16 @@ func TestManualCompactKeepRecentProtectsLatestExplicitUserInstruction(t *testing runner := NewRunner(generator) runner.userHomeDir = func() (string, error) { return t.TempDir(), nil } - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "old requirement"}, - {Role: provider.RoleAssistant, Content: "old answer"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, Content: "ack"}, - {Role: provider.RoleAssistant, Content: "follow up 1"}, - {Role: provider.RoleAssistant, Content: "follow up 2"}, - {Role: provider.RoleAssistant, Content: "follow up 3"}, - {Role: provider.RoleAssistant, Content: "follow up 4"}, - {Role: provider.RoleAssistant, Content: "follow up 5"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "old requirement"}, + {Role: providertypes.RoleAssistant, Content: "old answer"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "ack"}, + {Role: providertypes.RoleAssistant, Content: "follow up 1"}, + {Role: providertypes.RoleAssistant, Content: "follow up 2"}, + {Role: providertypes.RoleAssistant, Content: "follow up 3"}, + {Role: providertypes.RoleAssistant, Content: "follow up 4"}, + {Role: providertypes.RoleAssistant, Content: "follow up 5"}, } result, err := runner.Run(context.Background(), Input{ @@ -227,7 +227,7 @@ func TestManualCompactKeepRecentProtectsLatestExplicitUserInstruction(t *testing if len(generator.calls[0].ArchivedMessages) != 2 || len(generator.calls[0].RetainedMessages) != 7 { t.Fatalf("expected protected tail to start at latest user instruction, got %+v", generator.calls[0]) } - if result.Messages[1].Role != provider.RoleUser || result.Messages[1].Content != "latest explicit instruction" { + if result.Messages[1].Role != providertypes.RoleUser || result.Messages[1].Content != "latest explicit instruction" { t.Fatalf("expected retained latest explicit instruction, got %+v", result.Messages[1]) } } @@ -243,8 +243,8 @@ func TestManualCompactWritesTranscriptJSONL(t *testing.T) { Mode: ModeManual, SessionID: "session-jsonl", Workdir: filepath.Join(home, "workspace"), - Messages: []provider.Message{ - {Role: provider.RoleUser, Content: "hello"}, + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "hello"}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyKeepRecent, @@ -284,7 +284,7 @@ func TestManualCompactFailsWhenTranscriptWriteFails(t *testing.T) { Mode: ModeManual, SessionID: "session-fail", Workdir: t.TempDir(), - Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}, + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyKeepRecent, ManualKeepRecentMessages: 10, @@ -304,13 +304,13 @@ func TestManualCompactFullReplaceKeepsProtectedTail(t *testing.T) { home := t.TempDir() runner.userHomeDir = func() (string, error) { return home, nil } - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "old requirement"}, - {Role: provider.RoleAssistant, Content: "old answer"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-old", Name: "filesystem_grep", Arguments: "{}"}}}, - {Role: provider.RoleTool, ToolCallID: "call-old", Content: "old result"}, - {Role: provider.RoleAssistant, Content: "latest answer"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "old requirement"}, + {Role: providertypes.RoleAssistant, Content: "old answer"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, ToolCalls: []providertypes.ToolCall{{ID: "call-old", Name: "filesystem_grep", Arguments: "{}"}}}, + {Role: providertypes.RoleTool, ToolCallID: "call-old", Content: "old result"}, + {Role: providertypes.RoleAssistant, Content: "latest answer"}, } result, err := runner.Run(context.Background(), Input{ @@ -333,7 +333,7 @@ func TestManualCompactFullReplaceKeepsProtectedTail(t *testing.T) { if len(result.Messages) != 5 { t.Fatalf("expected summary plus protected tail, got %d", len(result.Messages)) } - if result.Messages[0].Role != provider.RoleAssistant { + if result.Messages[0].Role != providertypes.RoleAssistant { t.Fatalf("expected summary role assistant, got %q", result.Messages[0].Role) } if len(generator.calls) != 1 || len(generator.calls[0].RetainedMessages) != 4 { @@ -351,9 +351,9 @@ func TestManualCompactFullReplaceWithoutArchivableMessagesSkipsGenerator(t *test runner := NewRunner(generator) runner.userHomeDir = func() (string, error) { return t.TempDir(), nil } - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, Content: "latest answer"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "latest answer"}, } result, err := runner.Run(context.Background(), Input{ @@ -393,7 +393,7 @@ func TestRunManualRejectsUnsupportedStrategy(t *testing.T) { Mode: ModeManual, SessionID: "session-invalid-strategy", Workdir: t.TempDir(), - Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}, + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, Config: config.CompactConfig{ ManualStrategy: "unknown_strategy", ManualKeepRecentMessages: 10, @@ -415,7 +415,7 @@ func TestRunRejectsUnsupportedMode(t *testing.T) { Mode: Mode("unexpected"), SessionID: "session-invalid-mode", Workdir: t.TempDir(), - Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}, + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyKeepRecent, ManualKeepRecentMessages: 10, @@ -430,12 +430,12 @@ func TestRunRejectsUnsupportedMode(t *testing.T) { func TestCountMessageCharsUsesRunes(t *testing.T) { t.Parallel() - messages := []provider.Message{ + messages := []providertypes.Message{ {Role: "用户", Content: "你好"}, - {Role: provider.RoleAssistant, Content: "done"}, + {Role: providertypes.RoleAssistant, Content: "done"}, } got := countMessageChars(messages) - want := len([]rune("用户")) + len([]rune("你好")) + len([]rune(provider.RoleAssistant)) + len([]rune("done")) + want := len([]rune("用户")) + len([]rune("你好")) + len([]rune(providertypes.RoleAssistant)) + len([]rune("done")) if got != want { t.Fatalf("countMessageChars() = %d, want %d", got, want) } @@ -460,9 +460,9 @@ func TestSaveTranscriptUsesUniqueIDWithinSameTimestamp(t *testing.T) { Mode: ModeManual, SessionID: "session-dup-safe", Workdir: t.TempDir(), - Messages: []provider.Message{ - {Role: provider.RoleUser, Content: "hello"}, - {Role: provider.RoleAssistant, Content: "world"}, + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "hello"}, + {Role: providertypes.RoleAssistant, Content: "world"}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyFullReplace, @@ -506,11 +506,11 @@ func TestManualCompactGeneratorInvalidSummaryFails(t *testing.T) { Mode: ModeManual, SessionID: "session-invalid-summary", Workdir: t.TempDir(), - Messages: []provider.Message{ - {Role: provider.RoleUser, Content: "older"}, - {Role: provider.RoleAssistant, Content: "older answer"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, Content: "newer"}, + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "newer"}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyFullReplace, @@ -551,11 +551,11 @@ func TestManualCompactGeneratorEmptyBulletFails(t *testing.T) { Mode: ModeManual, SessionID: "session-empty-bullet", Workdir: t.TempDir(), - Messages: []provider.Message{ - {Role: provider.RoleUser, Content: "older"}, - {Role: provider.RoleAssistant, Content: "older answer"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, Content: "newer"}, + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "newer"}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyFullReplace, @@ -579,11 +579,11 @@ func TestManualCompactTruncationFailsWhenStructureBreaks(t *testing.T) { Mode: ModeManual, SessionID: "session-truncate-fail", Workdir: t.TempDir(), - Messages: []provider.Message{ - {Role: provider.RoleUser, Content: "older"}, - {Role: provider.RoleAssistant, Content: "older answer"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, Content: "newer"}, + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "newer"}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyFullReplace, @@ -607,8 +607,8 @@ func TestManualCompactKeepRecentWithoutEnoughMessagesSkipsGenerator(t *testing.T Mode: ModeManual, SessionID: "session-no-compact", Workdir: t.TempDir(), - Messages: []provider.Message{ - {Role: provider.RoleUser, Content: "single message"}, + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "single message"}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyKeepRecent, @@ -637,11 +637,11 @@ func TestManualCompactReturnsErrorWhenSummaryGeneratorIsMissing(t *testing.T) { Mode: ModeManual, SessionID: "session-missing-generator", Workdir: t.TempDir(), - Messages: []provider.Message{ - {Role: provider.RoleUser, Content: "older"}, - {Role: provider.RoleAssistant, Content: "older answer"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, Content: "newer"}, + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "newer"}, }, Config: config.CompactConfig{ ManualStrategy: config.CompactManualStrategyFullReplace, @@ -665,11 +665,11 @@ func TestManualCompactDefaultsToKeepRecentStrategyWhenManualStrategyIsEmpty(t *t Mode: ModeManual, SessionID: "session-default-strategy", Workdir: t.TempDir(), - Messages: []provider.Message{ - {Role: provider.RoleUser, Content: "old request"}, - {Role: provider.RoleAssistant, Content: "old answer"}, - {Role: provider.RoleUser, Content: "latest request"}, - {Role: provider.RoleAssistant, Content: "latest answer"}, + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "old request"}, + {Role: providertypes.RoleAssistant, Content: "old answer"}, + {Role: providertypes.RoleUser, Content: "latest request"}, + {Role: providertypes.RoleAssistant, Content: "latest answer"}, }, Config: config.CompactConfig{ ManualStrategy: "", diff --git a/internal/context/compact/transcript_store.go b/internal/context/compact/transcript_store.go index 7ff88b44..568f9b9f 100644 --- a/internal/context/compact/transcript_store.go +++ b/internal/context/compact/transcript_store.go @@ -13,7 +13,7 @@ import ( "strings" "time" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) const ( @@ -26,13 +26,13 @@ const ( ) type transcriptLine struct { - Index int `json:"index"` - Timestamp string `json:"timestamp"` - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []provider.ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` - IsError bool `json:"is_error,omitempty"` + Index int `json:"index"` + Timestamp string `json:"timestamp"` + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []providertypes.ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + IsError bool `json:"is_error,omitempty"` } // transcriptStore 只负责 compact 原始 transcript 的目录规划与安全落盘。 @@ -47,7 +47,7 @@ type transcriptStore struct { } // Save 按项目维度持久化当前 compact 前的 transcript,并返回 ID 与路径。 -func (s transcriptStore) Save(messages []provider.Message, sessionID string, workdir string) (string, string, error) { +func (s transcriptStore) Save(messages []providertypes.Message, sessionID string, workdir string) (string, string, error) { home, err := s.userHomeDir() if err != nil { return "", "", fmt.Errorf("compact: resolve user home: %w", err) @@ -85,7 +85,7 @@ func (s transcriptStore) Save(messages []provider.Message, sessionID string, wor Timestamp: now, Role: message.Role, Content: message.Content, - ToolCalls: append([]provider.ToolCall(nil), message.ToolCalls...), + ToolCalls: append([]providertypes.ToolCall(nil), message.ToolCalls...), ToolCallID: message.ToolCallID, IsError: message.IsError, } diff --git a/internal/context/compact/transcript_store_test.go b/internal/context/compact/transcript_store_test.go index b3616250..9425182d 100644 --- a/internal/context/compact/transcript_store_test.go +++ b/internal/context/compact/transcript_store_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) func TestTranscriptStoreSaveSanitizesSessionIDAndWritesJSONL(t *testing.T) { @@ -25,8 +25,8 @@ func TestTranscriptStoreSaveSanitizesSessionIDAndWritesJSONL(t *testing.T) { remove: os.Remove, } - id, path, err := store.Save([]provider.Message{ - {Role: provider.RoleUser, Content: "hello"}, + id, path, err := store.Save([]providertypes.Message{ + {Role: providertypes.RoleUser, Content: "hello"}, }, "session with spaces", filepath.Join(home, "workspace")) if err != nil { t.Fatalf("Save() error = %v", err) @@ -113,7 +113,7 @@ func TestTranscriptStoreSaveRemovesTemporaryFileWhenRenameFails(t *testing.T) { }, } - _, _, err := store.Save([]provider.Message{{Role: provider.RoleUser, Content: "hello"}}, "session", filepath.Join(home, "workspace")) + _, _, err := store.Save([]providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, "session", filepath.Join(home, "workspace")) if err == nil || !strings.Contains(err.Error(), "rename boom") { t.Fatalf("expected rename error, got %v", err) } diff --git a/internal/context/compact_prompt.go b/internal/context/compact_prompt.go index d79e56f4..4cbd8ce2 100644 --- a/internal/context/compact_prompt.go +++ b/internal/context/compact_prompt.go @@ -5,7 +5,7 @@ import ( "strings" "neo-code/internal/context/internalcompact" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) var compactSummarySystemPrompt = buildCompactSummarySystemPrompt() @@ -17,8 +17,8 @@ type CompactPromptInput struct { ManualKeepRecentMessages int ArchivedMessageCount int MaxSummaryChars int - ArchivedMessages []provider.Message - RetainedMessages []provider.Message + ArchivedMessages []providertypes.Message + RetainedMessages []providertypes.Message } // CompactPrompt is the provider-facing prompt pair for compact summaries. @@ -87,7 +87,7 @@ func buildCompactSummarySystemPrompt() string { } // renderCompactPromptMessages 将消息渲染为紧凑的 transcript 视图,减少冗余 JSON 噪音。 -func renderCompactPromptMessages(messages []provider.Message) string { +func renderCompactPromptMessages(messages []providertypes.Message) string { if len(messages) == 0 { return "[]" } @@ -120,7 +120,7 @@ func renderCompactPromptMessages(messages []provider.Message) string { } // renderCompactPromptToolCall 以单行形式渲染工具调用元信息,压缩摘要输入体积。 -func renderCompactPromptToolCall(call provider.ToolCall) string { +func renderCompactPromptToolCall(call providertypes.ToolCall) string { line := fmt.Sprintf( "tool_call id=%s name=%s arguments=%s", strings.TrimSpace(call.ID), diff --git a/internal/context/compact_prompt_test.go b/internal/context/compact_prompt_test.go index 5d38c006..676f02ab 100644 --- a/internal/context/compact_prompt_test.go +++ b/internal/context/compact_prompt_test.go @@ -5,7 +5,7 @@ import ( "testing" "neo-code/internal/context/internalcompact" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) func TestBuildCompactPromptIncludesFixedInstructionsAndBoundaries(t *testing.T) { @@ -17,20 +17,20 @@ func TestBuildCompactPromptIncludesFixedInstructionsAndBoundaries(t *testing.T) ManualKeepRecentMessages: 10, ArchivedMessageCount: 3, MaxSummaryChars: 1200, - ArchivedMessages: []provider.Message{ + ArchivedMessages: []providertypes.Message{ { - Role: provider.RoleUser, + Role: providertypes.RoleUser, Content: "legacy request\nwith details", }, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{\n \"path\": \"a.txt\"\n}"}, }, }, }, - RetainedMessages: []provider.Message{ - {Role: provider.RoleAssistant, Content: "recent answer"}, + RetainedMessages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "recent answer"}, }, }) diff --git a/internal/context/internalcompact/messages.go b/internal/context/internalcompact/messages.go index a81e891b..3574707b 100644 --- a/internal/context/internalcompact/messages.go +++ b/internal/context/internalcompact/messages.go @@ -3,7 +3,7 @@ package internalcompact import ( "strings" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) // MessageSpan 描述一段不可拆分的消息区间,并携带是否需要保护的尾部语义。 @@ -15,13 +15,13 @@ type MessageSpan struct { } // BuildMessageSpans 按工具调用原子块构建消息分段,并保护最后一条明确用户指令所在分段。 -func BuildMessageSpans(messages []provider.Message) []MessageSpan { +func BuildMessageSpans(messages []providertypes.Message) []MessageSpan { spans := make([]MessageSpan, 0, len(messages)) for i := 0; i < len(messages); { start := i end := i + 1 - if messages[start].Role == provider.RoleAssistant && len(messages[start].ToolCalls) > 0 { - for end < len(messages) && messages[end].Role == provider.RoleTool { + if messages[start].Role == providertypes.RoleAssistant && len(messages[start].ToolCalls) > 0 { + for end < len(messages) && messages[end].Role == providertypes.RoleTool { end++ } } @@ -73,9 +73,9 @@ func RetainedStartForKeepRecentMessages(spans []MessageSpan, keepMessages int) i } // lastExplicitUserMessageIndex 返回最后一条非空用户消息的位置,用于保护最近明确指令。 -func lastExplicitUserMessageIndex(messages []provider.Message) int { +func lastExplicitUserMessageIndex(messages []providertypes.Message) int { for index := len(messages) - 1; index >= 0; index-- { - if messages[index].Role == provider.RoleUser && strings.TrimSpace(messages[index].Content) != "" { + if messages[index].Role == providertypes.RoleUser && strings.TrimSpace(messages[index].Content) != "" { return index } } diff --git a/internal/context/internalcompact/messages_test.go b/internal/context/internalcompact/messages_test.go index 09c73181..b561ccec 100644 --- a/internal/context/internalcompact/messages_test.go +++ b/internal/context/internalcompact/messages_test.go @@ -3,24 +3,24 @@ package internalcompact import ( "testing" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) func TestBuildMessageSpansPreservesToolBlocksAndProtectedTail(t *testing.T) { t.Parallel() - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "old"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "old"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-1", Content: "result"}, - {Role: provider.RoleAssistant, Content: "after tool"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, Content: "latest answer"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "result"}, + {Role: providertypes.RoleAssistant, Content: "after tool"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "latest answer"}, } spans := BuildMessageSpans(messages) diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index e90a5d24..a881a8b9 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -4,7 +4,7 @@ import ( "strings" "neo-code/internal/context/internalcompact" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" "neo-code/internal/tools" ) @@ -16,12 +16,12 @@ const ( ) // microCompactMessages 对裁剪后的消息做只读投影式微压缩,仅清理旧工具结果内容。 -func microCompactMessages(messages []provider.Message) []provider.Message { +func microCompactMessages(messages []providertypes.Message) []providertypes.Message { return microCompactMessagesWithPolicies(messages, nil) } // microCompactMessagesWithPolicies 按工具策略对裁剪后的消息做只读投影式微压缩。 -func microCompactMessagesWithPolicies(messages []provider.Message, policies MicroCompactPolicySource) []provider.Message { +func microCompactMessagesWithPolicies(messages []providertypes.Message, policies MicroCompactPolicySource) []providertypes.Message { cloned := cloneContextMessages(messages) if len(cloned) == 0 { return cloned @@ -63,31 +63,31 @@ func microCompactMessagesWithPolicies(messages []provider.Message, policies Micr } // cloneContextMessages 深拷贝消息切片,避免读时投影污染 runtime 持有的原始会话消息。 -func cloneContextMessages(messages []provider.Message) []provider.Message { +func cloneContextMessages(messages []providertypes.Message) []providertypes.Message { if len(messages) == 0 { return nil } - cloned := make([]provider.Message, 0, len(messages)) + cloned := make([]providertypes.Message, 0, len(messages)) for _, message := range messages { next := message - next.ToolCalls = append([]provider.ToolCall(nil), message.ToolCalls...) + next.ToolCalls = append([]providertypes.ToolCall(nil), message.ToolCalls...) cloned = append(cloned, next) } return cloned } // isToolCallSpan 判断当前 span 是否是由 assistant tool call 起始的原子工具块。 -func isToolCallSpan(messages []provider.Message, span internalcompact.MessageSpan) bool { +func isToolCallSpan(messages []providertypes.Message, span internalcompact.MessageSpan) bool { if span.Start < 0 || span.Start >= len(messages) { return false } message := messages[span.Start] - return message.Role == provider.RoleAssistant && len(message.ToolCalls) > 0 + return message.Role == providertypes.RoleAssistant && len(message.ToolCalls) > 0 } // compactableToolCallIDs 返回 assistant tool call 中可参与微压缩的调用 ID 集合。 -func compactableToolCallIDs(calls []provider.ToolCall, policies MicroCompactPolicySource) map[string]struct{} { +func compactableToolCallIDs(calls []providertypes.ToolCall, policies MicroCompactPolicySource) map[string]struct{} { if len(calls) == 0 { return nil } @@ -119,7 +119,7 @@ func toolParticipatesInMicroCompact(toolName string, policies MicroCompactPolicy } // hasCompactableToolContent 判断工具块中是否存在会影响保留预算的有效工具结果内容。 -func hasCompactableToolContent(messages []provider.Message, span internalcompact.MessageSpan, compactableIDs map[string]struct{}) bool { +func hasCompactableToolContent(messages []providertypes.Message, span internalcompact.MessageSpan, compactableIDs map[string]struct{}) bool { for messageIndex := span.Start + 1; messageIndex < span.End; messageIndex++ { if shouldClearToolMessage(messages[messageIndex], compactableIDs) { return true @@ -129,8 +129,8 @@ func hasCompactableToolContent(messages []provider.Message, span internalcompact } // shouldClearToolMessage 判断一条 tool 消息是否满足旧结果清理条件。 -func shouldClearToolMessage(message provider.Message, compactableIDs map[string]struct{}) bool { - if message.Role != provider.RoleTool || message.IsError { +func shouldClearToolMessage(message providertypes.Message, compactableIDs map[string]struct{}) bool { + if message.Role != providertypes.RoleTool || message.IsError { return false } if compactableIDs == nil { diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go index a690b4c6..24e1e0e4 100644 --- a/internal/context/microcompact_test.go +++ b/internal/context/microcompact_test.go @@ -3,7 +3,7 @@ package context import ( "testing" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" "neo-code/internal/tools" ) @@ -19,31 +19,31 @@ func (s stubMicroCompactPolicySource) MicroCompactPolicy(name string) tools.Micr func TestMicroCompactMessagesClearsOlderCompactableToolResults(t *testing.T) { t.Parallel() - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "older user"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old read result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old read result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, Content: "current working reply"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "current working reply"}, } got := microCompactMessages(messages) @@ -71,10 +71,10 @@ func TestMicroCompactMessagesHandlesEmptyAndInvalidSpanInputs(t *testing.T) { t.Fatalf("expected nil input to remain nil, got %+v", got) } - assistantOnly := []provider.Message{ + assistantOnly := []providertypes.Message{ { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "", Name: "bash", Arguments: "{}"}, }, }, @@ -88,37 +88,37 @@ func TestMicroCompactMessagesHandlesEmptyAndInvalidSpanInputs(t *testing.T) { func TestMicroCompactMessagesKeepsProtectedTailUntouched(t *testing.T) { t.Parallel() - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "older user"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-0", Name: "filesystem_grep", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-0", Content: "old grep result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-0", Content: "old grep result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-1", Content: "recent read result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "recent read result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "bash", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-3", Content: "tail bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "tail bash result"}, } got := microCompactMessages(messages) @@ -139,36 +139,36 @@ func TestMicroCompactMessagesKeepsProtectedTailUntouched(t *testing.T) { func TestMicroCompactMessagesKeepsPreservedToolsErrorsAndOrphans(t *testing.T) { t.Parallel() - messages := []provider.Message{ + messages := []providertypes.Message{ { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "custom_tool", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-1", Content: "custom result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "custom result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "filesystem_edit", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-2", Content: "edit failed", IsError: true}, - {Role: provider.RoleTool, ToolCallID: "orphan", Content: "orphan result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "edit failed", IsError: true}, + {Role: providertypes.RoleTool, ToolCallID: "orphan", Content: "orphan result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "filesystem_write_file", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-3", Content: microCompactClearedMessage}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: microCompactClearedMessage}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-4", Name: "filesystem_grep", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-4", Content: ""}, + {Role: providertypes.RoleTool, ToolCallID: "call-4", Content: ""}, } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ @@ -194,33 +194,33 @@ func TestMicroCompactMessagesKeepsPreservedToolsErrorsAndOrphans(t *testing.T) { func TestMicroCompactMessagesClearsOnlyNonPreservedResultsInMixedToolSpan(t *testing.T) { t.Parallel() - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "older user"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, {ID: "call-2", Name: "custom_tool", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-1", Content: "read result"}, - {Role: provider.RoleTool, ToolCallID: "call-2", Content: "custom result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "read result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "custom result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "bash", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-3", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "recent bash result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-4", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-4", Content: "latest webfetch result"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, Content: "current reply"}, + {Role: providertypes.RoleTool, ToolCallID: "call-4", Content: "latest webfetch result"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "current reply"}, } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ @@ -240,30 +240,30 @@ func TestMicroCompactMessagesClearsOnlyNonPreservedResultsInMixedToolSpan(t *tes func TestMicroCompactMessagesTreatsNewToolsAsCompactableByDefault(t *testing.T) { t.Parallel() - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "older user"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "repo_search", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-1", Content: "old repo search result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "old repo search result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}) @@ -275,45 +275,45 @@ func TestMicroCompactMessagesTreatsNewToolsAsCompactableByDefault(t *testing.T) func TestMicroCompactMessagesSkipsEmptyRecentSpansWhenCountingRetainedBudget(t *testing.T) { t.Parallel() - messages := []provider.Message{ - {Role: provider.RoleUser, Content: "older user"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-1", Content: "older read result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "older read result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "filesystem_grep", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-2", Content: "middle grep result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "middle grep result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "filesystem_edit", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-3", Content: "near edit result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "near edit result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-4", Name: "bash", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-4", Content: "", IsError: true}, + {Role: providertypes.RoleTool, ToolCallID: "call-4", Content: "", IsError: true}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-5", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-5", Content: ""}, - {Role: provider.RoleUser, Content: "latest explicit instruction"}, - {Role: provider.RoleAssistant, Content: "current reply"}, + {Role: providertypes.RoleTool, ToolCallID: "call-5", Content: ""}, + {Role: providertypes.RoleUser, Content: "latest explicit instruction"}, + {Role: providertypes.RoleAssistant, Content: "current reply"}, } got := microCompactMessages(messages) @@ -337,8 +337,8 @@ func TestMicroCompactMessagesSkipsEmptyRecentSpansWhenCountingRetainedBudget(t * func TestMicroCompactMessagesSkipsToolMessagesWhenCompactableIDsMissing(t *testing.T) { t.Parallel() - messages := []provider.Message{ - {Role: provider.RoleTool, ToolCallID: "orphan", Content: "orphan result"}, + messages := []providertypes.Message{ + {Role: providertypes.RoleTool, ToolCallID: "orphan", Content: "orphan result"}, } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}) diff --git a/internal/context/trim.go b/internal/context/trim.go index 91d04e8f..d818a6fe 100644 --- a/internal/context/trim.go +++ b/internal/context/trim.go @@ -2,21 +2,21 @@ package context import ( "neo-code/internal/context/internalcompact" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) const maxRetainedMessageSpans = 10 // trimMessages 按消息分段裁剪上下文,并始终保护最近一条明确用户指令所在尾部。 -func trimMessages(messages []provider.Message) []provider.Message { +func trimMessages(messages []providertypes.Message) []providertypes.Message { spans := internalcompact.BuildMessageSpans(messages) if len(spans) <= maxRetainedMessageSpans { - return append([]provider.Message(nil), messages...) + return append([]providertypes.Message(nil), messages...) } start := spans[len(spans)-maxRetainedMessageSpans].Start if protectedStart, ok := internalcompact.ProtectedTailStart(spans); ok && protectedStart < start { start = protectedStart } - return append([]provider.Message(nil), messages[start:]...) + return append([]providertypes.Message(nil), messages[start:]...) } diff --git a/internal/context/trim_policy.go b/internal/context/trim_policy.go index e04da9a4..6e7c73b1 100644 --- a/internal/context/trim_policy.go +++ b/internal/context/trim_policy.go @@ -1,16 +1,18 @@ package context -import "neo-code/internal/provider" +import ( + providertypes "neo-code/internal/provider/types" +) // messageTrimPolicy 约束消息裁剪策略的最小接口,避免 Builder 直接持有裁剪细节。 type messageTrimPolicy interface { - Trim(messages []provider.Message) []provider.Message + Trim(messages []providertypes.Message) []providertypes.Message } // spanMessageTrimPolicy 以消息 span 为单位裁剪历史,确保 tool block 不被拆散。 type spanMessageTrimPolicy struct{} // Trim 返回保留关键 tool block 原子性的裁剪后消息副本。 -func (spanMessageTrimPolicy) Trim(messages []provider.Message) []provider.Message { +func (spanMessageTrimPolicy) Trim(messages []providertypes.Message) []providertypes.Message { return trimMessages(messages) } diff --git a/internal/context/types.go b/internal/context/types.go index e1bb0c93..53af2bfa 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -3,7 +3,7 @@ package context import ( "context" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" "neo-code/internal/tools" ) @@ -14,7 +14,7 @@ type Builder interface { // BuildInput contains the runtime state needed to assemble model context. type BuildInput struct { - Messages []provider.Message + Messages []providertypes.Message Metadata Metadata Compact CompactOptions } @@ -22,7 +22,7 @@ type BuildInput struct { // BuildResult is the provider-facing context produced for a single round. type BuildResult struct { SystemPrompt string - Messages []provider.Message + Messages []providertypes.Message } // MicroCompactPolicySource 定义 context 读取工具 micro compact 策略的最小依赖。 diff --git a/internal/provider/catalog/service_test.go b/internal/provider/catalog/service_test.go index 3d5d0b05..da3218dc 100644 --- a/internal/provider/catalog/service_test.go +++ b/internal/provider/catalog/service_test.go @@ -8,6 +8,7 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) func TestNewService(t *testing.T) { @@ -234,7 +235,7 @@ func containsModelDescriptorID(models []config.ModelDescriptor, modelID string) type catalogTestProvider struct{} -func (catalogTestProvider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error { +func (catalogTestProvider) Chat(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { return nil } diff --git a/internal/provider/discovery/discovery.go b/internal/provider/discovery/discovery.go deleted file mode 100644 index 4311117c..00000000 --- a/internal/provider/discovery/discovery.go +++ /dev/null @@ -1,50 +0,0 @@ -package discovery - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" -) - -type openAIModelsResponse struct { - Data []map[string]any `json:"data"` -} - -// FetchOpenAICompatibleModels fetches raw model objects from an OpenAI-compatible /models endpoint. -func FetchOpenAICompatibleModels(ctx context.Context, client *http.Client, baseURL string, apiKey string) ([]map[string]any, error) { - endpoint := strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/models" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, fmt.Errorf("provider discovery: build request: %w", err) - } - req.Header.Set("Accept", "application/json") - if strings.TrimSpace(apiKey) != "" { - req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(apiKey)) - } - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("provider discovery: send request: %w", err) - } - defer func(body io.ReadCloser) { - _ = body.Close() - }(resp.Body) - - if resp.StatusCode >= http.StatusBadRequest { - data, _ := io.ReadAll(resp.Body) - body := strings.TrimSpace(string(data)) - if body == "" { - body = resp.Status - } - return nil, fmt.Errorf("provider discovery: %s", body) - } - - var payload openAIModelsResponse - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return nil, fmt.Errorf("provider discovery: decode response: %w", err) - } - return payload.Data, nil -} diff --git a/internal/provider/discovery/discovery_test.go b/internal/provider/discovery/discovery_test.go deleted file mode 100644 index 50380a4c..00000000 --- a/internal/provider/discovery/discovery_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package discovery - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestFetchOpenAICompatibleModels(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/models" { - t.Fatalf("unexpected path %q", r.URL.Path) - } - if got := r.Header.Get("Authorization"); got != "Bearer test-key" { - t.Fatalf("unexpected auth header %q", got) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"data":[{"id":"gpt-test","context_window":128000,"extra":"kept"}]}`)) - })) - defer server.Close() - - models, err := FetchOpenAICompatibleModels(context.Background(), server.Client(), server.URL, "test-key") - if err != nil { - t.Fatalf("FetchOpenAICompatibleModels() error = %v", err) - } - if len(models) != 1 || models[0]["id"] != "gpt-test" { - t.Fatalf("unexpected models payload: %+v", models) - } - if models[0]["extra"] != "kept" { - t.Fatalf("expected unknown fields to remain in raw payload, got %+v", models[0]) - } -} - -func TestFetchOpenAICompatibleModelsHTTPError(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadGateway) - _, _ = w.Write([]byte("gateway failed")) - })) - defer server.Close() - - _, err := FetchOpenAICompatibleModels(context.Background(), server.Client(), server.URL, "test-key") - if err == nil || !strings.Contains(err.Error(), "gateway failed") { - t.Fatalf("expected gateway failure error, got %v", err) - } -} diff --git a/internal/provider/openai/discovery.go b/internal/provider/openai/discovery.go new file mode 100644 index 00000000..b20929d8 --- /dev/null +++ b/internal/provider/openai/discovery.go @@ -0,0 +1,51 @@ +package openai + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// openAIModelsResponse 表示 /models 端点的响应结构。 +type openAIModelsResponse struct { + Data []map[string]any `json:"data"` +} + +// fetchModels 从 OpenAI 兼容的 /models 端点获取原始模型列表。 +func (p *Provider) fetchModels(ctx context.Context) ([]map[string]any, error) { + endpoint := strings.TrimRight(strings.TrimSpace(p.cfg.BaseURL), "/") + "/models" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("openai provider: build models request: %w", err) + } + req.Header.Set("Accept", "application/json") + if strings.TrimSpace(p.cfg.APIKey) != "" { + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(p.cfg.APIKey)) + } + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("openai provider: send models request: %w", err) + } + defer func(body io.ReadCloser) { + _ = body.Close() + }(resp.Body) + + if resp.StatusCode >= http.StatusBadRequest { + data, _ := io.ReadAll(resp.Body) + body := strings.TrimSpace(string(data)) + if body == "" { + body = resp.Status + } + return nil, fmt.Errorf("openai provider: models endpoint %s", body) + } + + var payload openAIModelsResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("openai provider: decode models response: %w", err) + } + return payload.Data, nil +} diff --git a/internal/provider/openai/driver.go b/internal/provider/openai/driver.go new file mode 100644 index 00000000..fbde6820 --- /dev/null +++ b/internal/provider/openai/driver.go @@ -0,0 +1,35 @@ +package openai + +import ( + "context" + "net/http" + + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/provider/transport" +) + +// DriverName 是 OpenAI 驱动的注册标识。 +const DriverName = "openai" + +// defaultRetryTransport 返回内置的带重试的 HTTP Transport。 +func defaultRetryTransport() http.RoundTripper { + return transport.NewRetryTransport(http.DefaultTransport, transport.DefaultRetryConfig()) +} + +// Driver 返回 OpenAI 协议驱动的定义,供 Registry 注册使用。 +func Driver() provider.DriverDefinition { + return provider.DriverDefinition{ + Name: DriverName, + Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { + return New(cfg, withTransport(defaultRetryTransport())) + }, + Discover: func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { + p, err := New(cfg, withTransport(defaultRetryTransport())) + if err != nil { + return nil, err + } + return p.DiscoverModels(ctx) + }, + } +} diff --git a/internal/provider/openai/events.go b/internal/provider/openai/events.go new file mode 100644 index 00000000..50e13338 --- /dev/null +++ b/internal/provider/openai/events.go @@ -0,0 +1,63 @@ +package openai + +import ( + "context" + + providertypes "neo-code/internal/provider/types" +) + +// emitTextDelta 发送文本增量事件,空文本时跳过。 +func emitTextDelta(ctx context.Context, events chan<- providertypes.StreamEvent, text string) error { + if text == "" { + return nil + } + return emitStreamEvent(ctx, events, providertypes.NewTextDeltaStreamEvent(text)) +} + +// emitToolCallStart 发送工具调用开始事件,空名称时跳过。 +func emitToolCallStart(ctx context.Context, events chan<- providertypes.StreamEvent, index int, id, name string) error { + if name == "" { + return nil + } + return emitStreamEvent(ctx, events, providertypes.NewToolCallStartStreamEvent(index, id, name)) +} + +// emitToolCallDelta 发送工具调用参数增量事件。 +// id 为工具调用 ID,由上游 mergeToolCallDelta 从累积状态中传入。 +func emitToolCallDelta(ctx context.Context, events chan<- providertypes.StreamEvent, index int, id, argumentsDelta string) error { + if argumentsDelta == "" { + return nil + } + return emitStreamEvent(ctx, events, providertypes.NewToolCallDeltaStreamEvent(index, id, argumentsDelta)) +} + +// emitMessageDone 发送消息完成事件。 +func emitMessageDone(ctx context.Context, events chan<- providertypes.StreamEvent, finishReason string, usage *providertypes.Usage) error { + return emitStreamEvent(ctx, events, providertypes.NewMessageDoneStreamEvent(finishReason, usage)) +} + +// emitStreamEvent 通过 channel 安全发送流式事件,支持上下文取消和 nil channel 保护。 +func emitStreamEvent(ctx context.Context, events chan<- providertypes.StreamEvent, event providertypes.StreamEvent) error { + if events == nil { + return nil + } + + select { + case events <- event: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// flushDataLines 逐行处理缓冲的 data lines,每行作为独立 payload 通过 processChunk 处理。 +// SSE 规范允许同一事件内多行 data 拼接,但 OpenAI 实际行为是每行 data 为独立 JSON, +// 因此逐行处理更可靠,避免拼接产生无效 JSON。 +func flushDataLines(dataLines []string, processChunk func(string) error) error { + for _, line := range dataLines { + if err := processChunk(line); err != nil { + return err + } + } + return nil +} diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go deleted file mode 100644 index 2e2cbaf8..00000000 --- a/internal/provider/openai/openai.go +++ /dev/null @@ -1,501 +0,0 @@ -package openai - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "net/http" - "strings" - "time" - - "neo-code/internal/config" - "neo-code/internal/provider" - modeldiscovery "neo-code/internal/provider/discovery" - "neo-code/internal/provider/transport" -) - -type Provider struct { - cfg config.ResolvedProviderConfig - client *http.Client -} - -type buildOptions struct { - transport http.RoundTripper -} - -type buildOption func(*buildOptions) - -// withTransport 注入自定义 HTTP Transport(如 RetryTransport)。 -func withTransport(rt http.RoundTripper) buildOption { - return func(o *buildOptions) { - o.transport = rt - } -} - -const DriverName = "openai" - -// defaultRetryTransport 返回内置的带重试的 HTTP Transport。 -func defaultRetryTransport() http.RoundTripper { - return transport.NewRetryTransport(http.DefaultTransport, transport.DefaultRetryConfig()) -} - -// Driver 返回 OpenAI 协议驱动的定义。 -func Driver() provider.DriverDefinition { - return provider.DriverDefinition{ - Name: DriverName, - Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { - return New(cfg, withTransport(defaultRetryTransport())) - }, - Discover: func(ctx context.Context, cfg config.ResolvedProviderConfig) ([]config.ModelDescriptor, error) { - p, err := New(cfg, withTransport(defaultRetryTransport())) - if err != nil { - return nil, err - } - return p.DiscoverModels(ctx) - }, - } -} - -func New(cfg config.ResolvedProviderConfig, opts ...buildOption) (*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") - } - - o := &buildOptions{ - transport: http.DefaultTransport, - } - for _, apply := range opts { - apply(o) - } - - return &Provider{ - cfg: cfg, - client: &http.Client{ - Timeout: 90 * time.Second, - Transport: o.transport, - }, - }, nil -} - -func (p *Provider) DiscoverModels(ctx context.Context) ([]config.ModelDescriptor, error) { - rawModels, err := modeldiscovery.FetchOpenAICompatibleModels(ctx, p.client, p.cfg.BaseURL, p.cfg.APIKey) - if err != nil { - return nil, err - } - - descriptors := make([]config.ModelDescriptor, 0, len(rawModels)) - for _, raw := range rawModels { - descriptor, ok := config.DescriptorFromRawModel(raw) - if !ok { - continue - } - descriptors = append(descriptors, descriptor) - } - return config.MergeModelDescriptors(descriptors), nil -} - -// Chat 发起 SSE 流式对话请求。 -// 流中途断连或协议错误时直接返回错误,由上层调用方决定重试策略。 -func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error { - payload, err := p.buildRequest(req) - if err != nil { - return err - } - - body, err := json.Marshal(payload) - if err != nil { - return 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 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 fmt.Errorf("openai provider: send request: %w", err) - } - 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 p.parseError(resp) - } - - return p.consumeStream(ctx, resp.Body, events) -} - -func (p *Provider) buildRequest(req provider.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: provider.RoleSystem, - 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 -} - -// consumeStream 消费 SSE 响应流,使用有界读取器防止缓冲区溢出。 -func (p *Provider) consumeStream( - ctx context.Context, - body io.Reader, - events chan<- provider.StreamEvent, -) error { - reader := newBoundedSSEReader(body) - - var ( - finishReason string - usage provider.Usage - done bool - toolCalls = make(map[int]*provider.ToolCall) - ) - - dataLines := make([]string, 0, 4) - - // processChunk 解析单个 SSE data payload,发送事件。 - processChunk := func(payload string) error { - 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) - } - - extractStreamUsage(&usage, chunk.Usage) - - for _, choice := range chunk.Choices { - if choice.FinishReason != "" { - finishReason = choice.FinishReason - } - if choice.Delta.Content != "" { - if err := emitTextDelta(ctx, events, choice.Delta.Content); err != nil { - return err - } - } - for _, delta := range choice.Delta.ToolCalls { - if err := mergeToolCallDelta(ctx, events, toolCalls, delta); err != nil { - return err - } - } - } - return nil - } - - // finishStream 统一的流结束处理:发送 message_done 事件。 - finishStream := func() error { - return emitMessageDone(ctx, events, finishReason, &usage) - } - - flushPendingData := func() error { - defer func() { dataLines = dataLines[:0] }() - return flushDataLines(dataLines, processChunk) - } - - for { - line, err := reader.ReadLine() - - if err != nil && !errors.Is(err, io.EOF) { - // 非 EOF 的读取错误:先刷新缓冲的 data 行,再包装为流中断, - // 避免中断前最后一段数据丢失。 - if flushErr := flushPendingData(); flushErr != nil { - return flushErr - } - return fmt.Errorf("%w: %w", provider.ErrStreamInterrupted, err) - } - - trimmed := line - - switch { - case strings.HasPrefix(trimmed, "data:"): - dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))) - case trimmed == "": - if flushErr := flushPendingData(); flushErr != nil { - return flushErr - } - if done { - return finishStream() - } - case strings.HasPrefix(trimmed, ":"): - // SSE comment/heartbeat; ignore. - } - - if errors.Is(err, io.EOF) { - if flushErr := flushPendingData(); flushErr != nil { - return flushErr - } - return finishStream() - } - } -} - -func (p *Provider) parseError(resp *http.Response) error { - data, readErr := io.ReadAll(resp.Body) - if readErr != nil { - return provider.NewProviderErrorFromStatus(resp.StatusCode, - fmt.Sprintf("openai provider: read error response: %v", readErr)) - } - - var parsed openAIErrorResponse - if err := json.Unmarshal(data, &parsed); err == nil && strings.TrimSpace(parsed.Error.Message) != "" { - return provider.NewProviderErrorFromStatus(resp.StatusCode, parsed.Error.Message) - } - - bodyText := strings.TrimSpace(string(data)) - if bodyText == "" { - return provider.NewProviderErrorFromStatus(resp.StatusCode, resp.Status) - } - - return provider.NewProviderErrorFromStatus(resp.StatusCode, bodyText) -} - -func toOpenAIMessage(message provider.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<- provider.StreamEvent, text string) error { - if text == "" { - return nil - } - return emitStreamEvent(ctx, events, provider.NewTextDeltaStreamEvent(text)) -} - -func emitToolCallStart(ctx context.Context, events chan<- provider.StreamEvent, index int, id, name string) error { - if name == "" { - return nil - } - return emitStreamEvent(ctx, events, provider.NewToolCallStartStreamEvent(index, id, name)) -} - -// emitToolCallDelta 发送工具调用参数增量事件。 -// id 为工具调用 ID,由上游 mergeToolCallDelta 从累积状态中传入。 -func emitToolCallDelta(ctx context.Context, events chan<- provider.StreamEvent, index int, id, argumentsDelta string) error { - if argumentsDelta == "" { - return nil - } - return emitStreamEvent(ctx, events, provider.NewToolCallDeltaStreamEvent(index, id, argumentsDelta)) -} - -// emitMessageDone 发送消息完成事件。 -func emitMessageDone(ctx context.Context, events chan<- provider.StreamEvent, finishReason string, usage *provider.Usage) error { - return emitStreamEvent(ctx, events, provider.NewMessageDoneStreamEvent(finishReason, usage)) -} - -// extractStreamUsage 从 OpenAI usage 响应提取并覆盖累积的 token 统计。 -func extractStreamUsage(usage *provider.Usage, raw *openAIUsage) { - if raw == nil { - return - } - *usage = provider.Usage{ - InputTokens: raw.PromptTokens, - OutputTokens: raw.CompletionTokens, - TotalTokens: raw.TotalTokens, - } -} - -// mergeToolCallDelta 将单个 tool call delta 累积到 toolCalls map 中。 -// 首次发现带名称的 delta 时发送 tool_call_start 事件; -// 每次收到 arguments 增量时发送 tool_call_delta 事件。 -func mergeToolCallDelta(ctx context.Context, events chan<- provider.StreamEvent, toolCalls map[int]*provider.ToolCall, delta toolCallDelta) error { - call, exists := toolCalls[delta.Index] - if !exists { - call = &provider.ToolCall{} - toolCalls[delta.Index] = call - } - - hadName := strings.TrimSpace(call.Name) != "" - - if id := strings.TrimSpace(delta.ID); id != "" { - call.ID = id - } - if name := strings.TrimSpace(delta.Function.Name); name != "" { - call.Name = name - } - - if !hadName && strings.TrimSpace(call.Name) != "" { - if err := emitToolCallStart(ctx, events, delta.Index, call.ID, call.Name); err != nil { - return err - } - } - - // 发送参数增量事件(同一 chunk 可能同时携带 name 和 arguments) - if args := delta.Function.Arguments; args != "" { - call.Arguments += args - if err := emitToolCallDelta(ctx, events, delta.Index, call.ID, args); err != nil { - return err - } - } - return nil -} - -func emitStreamEvent(ctx context.Context, events chan<- provider.StreamEvent, event provider.StreamEvent) error { - if events == nil { - return nil - } - - select { - case events <- event: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -// flushDataLines 将缓冲的 data lines 合并为单个 payload 并通过 processChunk 处理。 -func flushDataLines(dataLines []string, processChunk func(string) error) error { - if len(dataLines) == 0 { - return nil - } - return processChunk(strings.Join(dataLines, "\n")) -} - -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"` - Code string `json:"code,omitempty"` - } `json:"error"` -} diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index bfbca884..33179a2e 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -9,9 +9,11 @@ import ( "net/http/httptest" "strings" "testing" + "time" "neo-code/internal/config" - domain "neo-code/internal/provider" + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) func TestDriver(t *testing.T) { @@ -73,7 +75,6 @@ func TestNewValidationErrors(t *testing.T) { t.Run("invalid config validate fails", func(t *testing.T) { t.Parallel() - // 空字符串的 BaseURL 和 Model 会导致 Validate 失败(取决于 config 实现) cfg := config.ResolvedProviderConfig{ ProviderConfig: config.ProviderConfig{ Driver: DriverName, @@ -84,12 +85,9 @@ func TestNewValidationErrors(t *testing.T) { APIKey: "test-key", } _, err := New(cfg) - // 验证失败时应该返回错误 if err != nil { - // 预期行为:config 校验不通过 return } - // 如果校验通过了,也接受(取决于具体实现) }) } @@ -97,7 +95,7 @@ func TestNewDefaultTransportWhenNoOption(t *testing.T) { t.Parallel() cfg := resolvedConfig("", "") - provider, err := New(cfg) // 不传任何 buildOption + provider, err := New(cfg) if err != nil { t.Fatalf("New() error = %v", err) } @@ -150,106 +148,726 @@ func TestDiscoverModels(t *testing.T) { } } -func TestEmitToolCallDelta(t *testing.T) { +// --- toOpenAIMessage 转换测试 --- + +func TestToOpenAIMessage_BasicMessage(t *testing.T) { + t.Parallel() + + msg := providertypes.Message{ + Role: "user", + Content: "hello world", + } + result := toOpenAIMessage(msg) + + if result.Role != "user" || result.Content != "hello world" { + t.Fatalf("unexpected basic message: role=%q content=%q", result.Role, result.Content) + } + if result.ToolCallID != "" || len(result.ToolCalls) > 0 { + t.Fatal("basic message should not have tool call fields") + } +} + +func TestToOpenAIMessage_ToolRoleMessage(t *testing.T) { + t.Parallel() + + msg := providertypes.Message{ + Role: "tool", + Content: "result data", + ToolCallID: "call_123", + } + result := toOpenAIMessage(msg) + + if result.Role != "tool" || result.ToolCallID != "call_123" { + t.Fatalf("unexpected tool message: role=%q toolCallID=%q", result.Role, result.ToolCallID) + } +} + +func TestToOpenAIMessage_AssistantWithToolCalls(t *testing.T) { + t.Parallel() + + msg := providertypes.Message{ + Role: "assistant", + ToolCalls: []providertypes.ToolCall{ + {ID: "call_1", Name: "read_file", Arguments: `{"path":"main.go"}`}, + {ID: "call_2", Name: "write_file", Arguments: `{"path":"test.go","content":"..."}`}, + }, + } + result := toOpenAIMessage(msg) + + if len(result.ToolCalls) != 2 { + t.Fatalf("expected 2 tool calls, got %d", len(result.ToolCalls)) + } + tc1 := result.ToolCalls[0] + if tc1.ID != "call_1" || tc1.Type != "function" { + t.Fatalf("unexpected first tool call: id=%q type=%q", tc1.ID, tc1.Type) + } + if tc1.Function.Name != "read_file" || tc1.Function.Arguments != `{"path":"main.go"}` { + t.Fatalf("unexpected first function: name=%q args=%q", tc1.Function.Name, tc1.Function.Arguments) + } + tc2 := result.ToolCalls[1] + if tc2.Function.Name != "write_file" { + t.Fatalf("unexpected second function name: %q", tc2.Function.Name) + } +} + +func TestToOpenAIMessage_EmptyToolCalls(t *testing.T) { + t.Parallel() + + msg := providertypes.Message{Role: "user", Content: "test"} + result := toOpenAIMessage(msg) + if len(result.ToolCalls) != 0 { + t.Fatalf("expected no tool calls for user message, got %d", len(result.ToolCalls)) + } +} + +// --- extractStreamUsage 测试 --- + +func TestExtractStreamUsage_NilInput(t *testing.T) { + t.Parallel() + + var usage providertypes.Usage + extractStreamUsage(&usage, nil) + if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.TotalTokens != 0 { + t.Fatalf("expected zero values for nil input, got %+v", usage) + } +} + +func TestExtractStreamUsage_NormalValues(t *testing.T) { + t.Parallel() + + var usage providertypes.Usage + raw := &openAIUsage{PromptTokens: 100, CompletionTokens: 50, TotalTokens: 150} + extractStreamUsage(&usage, raw) + if usage.InputTokens != 100 || usage.OutputTokens != 50 || usage.TotalTokens != 150 { + t.Fatalf("unexpected usage values: %+v", usage) + } +} + +func TestExtractStreamUsage_ZeroValues(t *testing.T) { + t.Parallel() + + var usage providertypes.Usage + usage.InputTokens = 999 + raw := &openAIUsage{} + extractStreamUsage(&usage, raw) + if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.TotalTokens != 0 { + t.Fatalf("expected zero values to overwrite previous, got %+v", usage) + } +} + +func TestExtractStreamUsage_MultipleOverwrites(t *testing.T) { + t.Parallel() + + var usage providertypes.Usage + extractStreamUsage(&usage, &openAIUsage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}) + extractStreamUsage(&usage, &openAIUsage{PromptTokens: 20, CompletionTokens: 10, TotalTokens: 30}) + if usage.TotalTokens != 30 { + t.Fatalf("expected last write to win (total=30), got %d", usage.TotalTokens) + } +} + +// --- buildRequest 边界测试 --- + +func TestBuildRequest_EmptyModelReturnsError(t *testing.T) { + t.Parallel() + + // 直接构造 Provider 跳过 New() 的 Validate 校验, + // 以便测试 buildRequest 对空 model 的独立校验。 + p := &Provider{ + cfg: config.ResolvedProviderConfig{ + ProviderConfig: config.ProviderConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: config.OpenAIDefaultBaseURL, + Model: "", + APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, + }, + APIKey: "test-key", + }, + client: &http.Client{}, + } + + _, buildErr := p.buildRequest(providertypes.ChatRequest{}) + if buildErr == nil { + t.Fatal("expected error for empty model") + } + if !strings.Contains(buildErr.Error(), "model is empty") { + t.Fatalf("unexpected error message: %v", buildErr) + } +} + +func TestBuildRequest_FallsBackToConfigModel(t *testing.T) { + t.Parallel() + + p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + payload, err := p.buildRequest(providertypes.ChatRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + if err != nil { + t.Fatalf("buildRequest() error = %v", err) + } + if payload.Model != config.OpenAIDefaultModel { + t.Fatalf("expected model %q, got %q", config.OpenAIDefaultModel, payload.Model) + } +} + +func TestBuildRequest_RequestModelTakesPrecedence(t *testing.T) { + t.Parallel() + + p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + payload, err := p.buildRequest(providertypes.ChatRequest{Model: "gpt-4-custom", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + if err != nil { + t.Fatalf("buildRequest() error = %v", err) + } + if payload.Model != "gpt-4-custom" { + t.Fatalf("expected model %q, got %q", "gpt-4-custom", payload.Model) + } +} + +func TestBuildRequest_NoSystemPrompt(t *testing.T) { + t.Parallel() + + p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + payload, err := p.buildRequest(providertypes.ChatRequest{SystemPrompt: "", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + if err != nil { + t.Fatalf("buildRequest() error = %v", err) + } + for _, msg := range payload.Messages { + if msg.Role == "system" { + t.Fatal("expected no system message when SystemPrompt is empty") + } + } +} + +func TestBuildRequest_NoTools(t *testing.T) { + t.Parallel() + + p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + payload, err := p.buildRequest(providertypes.ChatRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, Tools: nil}) + if err != nil { + t.Fatalf("buildRequest() error = %v", err) + } + if payload.ToolChoice != "" || len(payload.Tools) != 0 { + t.Fatalf("expected no tools, got choice=%q tools=%d", payload.ToolChoice, len(payload.Tools)) + } +} + +func TestBuildRequest_EmptyToolsSlice(t *testing.T) { + t.Parallel() + + p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + payload, err := p.buildRequest(providertypes.ChatRequest{Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, Tools: []providertypes.ToolSpec{}}) + if err != nil { + t.Fatalf("buildRequest() error = %v", err) + } + if payload.ToolChoice != "" || len(payload.Tools) != 0 { + t.Fatalf("expected empty tools for empty slice, got choice=%q tools=%d", payload.ToolChoice, len(payload.Tools)) + } +} + +func TestBuildRequest_MultipleTools(t *testing.T) { + t.Parallel() + + p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + payload, err := p.buildRequest(providertypes.ChatRequest{ + Messages: []providertypes.Message{{Role: "user", Content: "use tools"}}, + Tools: []providertypes.ToolSpec{ + {Name: "tool_a", Description: "Tool A", Schema: map[string]any{"type": "object"}}, + {Name: "tool_b", Description: "Tool B", Schema: map[string]any{"type": "object"}}, + }, + }) + if err != nil { + t.Fatalf("buildRequest() error = %v", err) + } + if len(payload.Tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(payload.Tools)) + } + if payload.ToolChoice != "auto" { + t.Fatalf("expected tool_choice=auto, got %q", payload.ToolChoice) + } +} + +func TestBuildRequest_WhitespaceSystemPromptSkipped(t *testing.T) { + t.Parallel() + + p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + payload, err := p.buildRequest(providertypes.ChatRequest{SystemPrompt: " ", Messages: []providertypes.Message{{Role: "user", Content: "hi"}}}) + if err != nil { + t.Fatalf("buildRequest() error = %v", err) + } + for _, msg := range payload.Messages { + if msg.Role == "system" { + t.Fatal("expected no system message for whitespace-only system prompt") + } + } +} + +// --- consumeStream SSE 场景测试 --- + +func TestConsumeStream_SSECommentIgnored(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + sseData := `: heartbeat +data: {"id":"a","choices":[{"delta":{"content":"ok"},"finish_reason":""}]} +data: [DONE] + +` + events := make(chan providertypes.StreamEvent, 4) + err = p.consumeStream(context.Background(), strings.NewReader(sseData), events) + if err != nil { + t.Fatalf("consumeStream() error = %v", err) + } + drained := drainStreamEvents(events) + var foundText bool + for _, evt := range drained { + if evt.Type == providertypes.StreamEventTextDelta && requireTextDeltaPayload(t, evt).Text == "ok" { + foundText = true + } + } + if !foundText { + t.Fatal("expected text_delta event after SSE comment") + } +} + +func TestConsumeStream_ChunkErrorInPayload(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + sseData := `data: {"error":{"message":"rate limit exceeded"}} +` + events := make(chan providertypes.StreamEvent, 1) + err = p.consumeStream(context.Background(), strings.NewReader(sseData), events) + if err == nil { + t.Fatal("expected error for chunk with error field") + } + if !strings.Contains(err.Error(), "rate limit exceeded") { + t.Fatalf("expected rate limit error, got: %v", err) + } +} + +func TestConsumeStream_MultiLineDataPayload(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + sseData := `data: {"id":"a","choices":[{"delta":{"content":"part1"},"finish_reason":""}]} +data: {"id":"b","choices":[{"delta":{"content":"part2"},"finish_reason":"stop"}]} + +` + events := make(chan providertypes.StreamEvent, 8) + err = p.consumeStream(context.Background(), strings.NewReader(sseData), events) + if err != nil { + t.Fatalf("consumeStream() error = %v", err) + } + drained := drainStreamEvents(events) + if len(drained) == 0 { + t.Fatal("expected events from multi-line data payload") + } +} + +func TestConsumeStream_EOFWithoutDone(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + sseData := `data: {"id":"a","choices":[{"delta":{"content":"partial"},"finish_reason":""}]} +` + events := make(chan providertypes.StreamEvent, 4) + err = p.consumeStream(context.Background(), strings.NewReader(sseData), events) + if err != nil { + t.Fatalf("consumeStream() error = %v", err) + } + drained := drainStreamEvents(events) + var foundText bool + for _, evt := range drained { + if evt.Type == providertypes.StreamEventTextDelta { + foundText = true + } + } + if !foundText { + t.Fatal("expected text_delta event before EOF") + } +} + +func TestConsumeStream_ContextCancellation(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + sseData := `data: {"id":"a","choices":[{"delta":{"content":"should not emit"}}]} + +` + events := make(chan providertypes.StreamEvent, 1) + err = p.consumeStream(ctx, strings.NewReader(sseData), events) + if err != nil && !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled or nil, got: %v", err) + } +} + +func TestConsumeStream_FinishReasonAccumulation(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + sseData := `data: {"id":"a","choices":[{"delta":{"content":"text"},"finish_reason":""}]} +data: {"id":"b","choices":[{"index":0,"finish_reason":"stop"}]} +data: [DONE] + +` + events := make(chan providertypes.StreamEvent, 8) + err = p.consumeStream(context.Background(), strings.NewReader(sseData), events) + if err != nil { + t.Fatalf("consumeStream() error = %v", err) + } + + drained := drainStreamEvents(events) + var donePayload *providertypes.MessageDonePayload + for _, evt := range drained { + if evt.Type == providertypes.StreamEventMessageDone { + p := requireMessageDonePayload(t, evt) + donePayload = &p + } + } + if donePayload == nil { + t.Fatal("expected message_done event") + } + if donePayload.FinishReason != "stop" { + t.Fatalf("expected finish_reason=stop, got %q", donePayload.FinishReason) + } +} + +// --- emit 函数守卫和边界测试 --- + +func TestEmitTextDelta_NilEventsGuard(t *testing.T) { + t.Parallel() + if err := emitTextDelta(context.Background(), nil, "some text"); err != nil { + t.Fatalf("expected nil events guard to return nil, got %v", err) + } +} + +func TestEmitTextDelta_EmptyTextGuard(t *testing.T) { + t.Parallel() + events := make(chan providertypes.StreamEvent, 1) + if err := emitTextDelta(context.Background(), events, ""); err != nil { + t.Fatalf("expected empty text guard to return nil, got %v", err) + } + select { + case <-events: + t.Fatal("expected no event for empty text") + default: + } +} + +func TestEmitStreamEvent_NilEventsChannel(t *testing.T) { + t.Parallel() + event := providertypes.NewTextDeltaStreamEvent("test") + if err := emitStreamEvent(context.Background(), nil, event); err != nil { + t.Fatalf("expected nil channel to return nil, got %v", err) + } +} + +func TestEmitStreamEvent_NormalSend(t *testing.T) { + t.Parallel() + events := make(chan providertypes.StreamEvent, 1) + event := providertypes.NewTextDeltaStreamEvent("hello") + if err := emitStreamEvent(context.Background(), events, event); err != nil { + t.Fatalf("emitStreamEvent() error = %v", err) + } + got := <-events + if got.Type != providertypes.StreamEventTextDelta { + t.Fatalf("unexpected event type: %s", got.Type) + } +} + +func TestEmitStreamEvent_ContextCancelled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + events := make(chan providertypes.StreamEvent) + event := providertypes.NewTextDeltaStreamEvent("test") + + err := emitStreamEvent(ctx, events, event) + if err == nil { + t.Fatal("expected context cancellation error") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got: %v", err) + } +} + +// --- flushDataLines 测试 --- + +func TestFlushDataLines_EmptyLines(t *testing.T) { + t.Parallel() + called := false + err := flushDataLines([]string{}, func(string) error { called = true; return nil }) + if err != nil { + t.Fatalf("flushDataLines() error = %v", err) + } + if called { + t.Fatal("processChunk should not be called for empty lines") + } +} + +func TestFlushDataLines_SingleLine(t *testing.T) { + t.Parallel() + var received string + err := flushDataLines([]string{"line1"}, func(p string) error { received = p; return nil }) + if err != nil { + t.Fatalf("flushDataLines() error = %v", err) + } + if received != "line1" { + t.Fatalf("expected %q, got %q", "line1", received) + } +} + +func TestFlushDataLines_MultipleLinesProcessedIndividually(t *testing.T) { + t.Parallel() + var received []string + err := flushDataLines([]string{"a", "b", "c"}, func(p string) error { received = append(received, p); return nil }) + if err != nil { + t.Fatalf("flushDataLines() error = %v", err) + } + if len(received) != 3 || received[0] != "a" || received[1] != "b" || received[2] != "c" { + t.Fatalf("expected each line processed individually, got %v", received) + } +} + +func TestFlushDataLines_ProcessChunkError(t *testing.T) { + t.Parallel() + expectedErr := errors.New("process error") + err := flushDataLines([]string{"data"}, func(string) error { return expectedErr }) + if err != expectedErr { + t.Fatalf("expected processChunk error, got %v", err) + } +} + +// --- DiscoverModels 错误场景测试 --- + +func TestDiscoverModels_HTTPError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("internal error")) + })) + defer server.Close() + + p, err := New(resolvedConfig(server.URL, "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = server.Client() + + models, err := p.DiscoverModels(context.Background()) + if err == nil { + t.Fatal("expected error for HTTP 500 response") + } + if models != nil { + t.Fatalf("expected nil models on error, got %d models", len(models)) + } +} + +func TestDiscoverModels_NetworkError(t *testing.T) { + t.Parallel() + + p, err := New(resolvedConfig("http://127.0.0.1:1", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = &http.Client{Timeout: time.Millisecond * 10} + + _, err = p.DiscoverModels(context.Background()) + if err == nil { + t.Fatal("expected error for unreachable server") + } +} + +// --- mergeToolCallDelta 边界测试 --- + +func TestMergeToolCallDelta_MultipleIndices(t *testing.T) { t.Parallel() - t.Run("nil events guard", func(t *testing.T) { - t.Parallel() - if err := emitToolCallDelta(context.Background(), nil, 0, "", "args"); err != nil { - t.Fatalf("expected nil events guard to return nil, got %v", err) - } - }) + events := make(chan providertypes.StreamEvent, 8) + toolCalls := make(map[int]*providertypes.ToolCall) - t.Run("empty arguments guard", func(t *testing.T) { - t.Parallel() - events := make(chan domain.StreamEvent, 1) - if err := emitToolCallDelta(context.Background(), events, 0, "", ""); err != nil { - t.Fatalf("expected empty arguments guard to return nil, got %v", err) - } - select { - case <-events: - t.Fatal("expected no event for empty arguments") - default: - } + mergeToolCallDelta(context.Background(), events, toolCalls, toolCallDelta{ + Index: 0, ID: "call_0", + Function: openAIFunctionCall{Name: "tool_a", Arguments: `{"arg":"a"`}, }) - - t.Run("normal send", func(t *testing.T) { - t.Parallel() - events := make(chan domain.StreamEvent, 1) - if err := emitToolCallDelta(context.Background(), events, 3, "call_123", `{"path":"main.go"}`); err != nil { - t.Fatalf("emitToolCallDelta() error = %v", err) - } - got := <-events - payload := requireToolCallDeltaPayload(t, got) - if got.Type != domain.StreamEventToolCallDelta || payload.Index != 3 || payload.ArgumentsDelta != `{"path":"main.go"}` || payload.ID != "call_123" { - t.Fatalf("unexpected event: %+v", got) - } + mergeToolCallDelta(context.Background(), events, toolCalls, toolCallDelta{ + Index: 1, ID: "call_1", + Function: openAIFunctionCall{Name: "tool_b", Arguments: `{"arg":"b"}`}, }) - - t.Run("context cancellation", func(t *testing.T) { - t.Parallel() - cancelledCtx, cancel := context.WithCancel(context.Background()) - cancel() - if err := emitToolCallDelta(cancelledCtx, make(chan domain.StreamEvent), 0, "", "args"); err == nil { - t.Fatal("expected cancellation error") - } + mergeToolCallDelta(context.Background(), events, toolCalls, toolCallDelta{ + Index: 0, + Function: openAIFunctionCall{Arguments: `,"more":"data"}`}, }) + + if len(toolCalls) != 2 { + t.Fatalf("expected 2 tool calls, got %d", len(toolCalls)) + } + call0 := toolCalls[0] + if call0.Name != "tool_a" || call0.ID != "call_0" { + t.Fatalf("unexpected tool call 0: %+v", call0) + } + expectedArgs0 := `{"arg":"a","more":"data"}` + if call0.Arguments != expectedArgs0 { + t.Fatalf("expected arguments %q for call 0, got %q", expectedArgs0, call0.Arguments) + } + call1 := toolCalls[1] + if call1.Name != "tool_b" || call1.ID != "call_1" { + t.Fatalf("unexpected tool call 1: %+v", call1) + } } -func TestEmitMessageDone(t *testing.T) { +func TestMergeToolCallDelta_IDUpdateOnly(t *testing.T) { t.Parallel() - t.Run("nil events guard", func(t *testing.T) { - t.Parallel() - if err := emitMessageDone(context.Background(), nil, "stop", nil); err != nil { - t.Fatalf("expected nil events guard to return nil, got %v", err) - } - }) + events := make(chan providertypes.StreamEvent, 4) + toolCalls := make(map[int]*providertypes.ToolCall) - t.Run("normal send", func(t *testing.T) { - t.Parallel() - events := make(chan domain.StreamEvent, 1) - usage := &domain.Usage{TotalTokens: 100} - if err := emitMessageDone(context.Background(), events, "stop", usage); err != nil { - t.Fatalf("emitMessageDone() error = %v", err) - } - got := <-events - payload := requireMessageDonePayload(t, got) - if got.Type != domain.StreamEventMessageDone || payload.FinishReason != "stop" || payload.Usage == nil || payload.Usage.TotalTokens != 100 { - t.Fatalf("unexpected event: %+v", got) - } - }) + mergeToolCallDelta(context.Background(), events, toolCalls, toolCallDelta{Index: 0, ID: "call_only_id"}) - t.Run("context cancellation", func(t *testing.T) { - t.Parallel() - cancelledCtx, cancel := context.WithCancel(context.Background()) - cancel() - if err := emitMessageDone(cancelledCtx, make(chan domain.StreamEvent), "stop", nil); err == nil { - t.Fatal("expected cancellation error") + call := toolCalls[0] + if call == nil { + t.Fatal("expected tool call entry to be created") + } + if call.ID != "call_only_id" { + t.Fatalf("expected ID %q, got %q", "call_only_id", call.ID) + } + select { + case <-events: + t.Fatal("expected no event for ID-only delta") + default: + } +} + +// --- Chat 集成测试 --- + +func TestChat_BaseURLTrailingSlashHandled(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "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) } - }) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} +data: [DONE] + +`)) + })) + defer server.Close() + + p, err := New(resolvedConfig(server.URL+"/", config.OpenAIDefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + p.client = server.Client() + + events := make(chan providertypes.StreamEvent, 4) + err = p.Chat(context.Background(), providertypes.ChatRequest{ + Model: config.OpenAIDefaultModel, + Messages: []providertypes.Message{{Role: "user", Content: "hi"}}, + }, events) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } } -func resolvedConfig(baseURL string, model string) config.ResolvedProviderConfig { - if strings.TrimSpace(baseURL) == "" { - baseURL = config.OpenAIDefaultBaseURL +// --- parseError 边界测试 --- + +func TestParseError_ReadBodyFailure(t *testing.T) { + t.Parallel() + + p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) } - if strings.TrimSpace(model) == "" { - model = config.OpenAIDefaultModel + + readErr := errors.New("simulated read failure") + resp := &http.Response{Status: "400 Bad Request", StatusCode: 400, Body: &failingReadCloser{err: readErr}} + + err = p.parseError(resp) + if err == nil { + t.Fatal("expected error when body read fails") } + if !strings.Contains(err.Error(), "read error response") { + t.Fatalf("expected read error in message, got: %v", err) + } +} - return config.ResolvedProviderConfig{ - ProviderConfig: config.ProviderConfig{ - Name: DriverName, - Driver: DriverName, - BaseURL: baseURL, - Model: model, - APIKeyEnv: config.OpenAIDefaultAPIKeyEnv, - }, - APIKey: "test-key", +func TestParseError_InvalidJSONBody(t *testing.T) { + t.Parallel() + + p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + resp := &http.Response{Status: "400 Bad Request", StatusCode: 400, Body: ioNopCloser("this is not json at all")} + err = p.parseError(resp) + if err == nil { + t.Fatal("expected error for non-JSON body") + } + if !strings.Contains(err.Error(), "this is not json at all") { + t.Fatalf("expected plain text fallback, got: %v", err) } } +// --- 原有保留的集成测试(保持兼容) --- + func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") @@ -268,118 +886,32 @@ func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { 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, - }, - }) + 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","search_string":"old"`}}}}}}}) + writeSSEChunk(t, w, map[string]any{"choices": []map[string]any{{"index": 0, "delta": map[string]any{"content": "world"}}}}) + writeSSEChunk(t, w, map[string]any{"choices": []map[string]any{{"index": 0, "delta": map[string]any{"tool_calls": []map[string]any{{"index": 0, "function": map[string]any{"arguments": `,"replace_string":"new"}`}}}}}}}) + writeSSEChunk(t, w, map[string]any{"choices": []map[string]any{{"index": 0, "finish_reason": "tool_calls"}}, "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")) + p, err := New(resolvedConfig(server.URL, "gpt-5.4")) if err != nil { t.Fatalf("New() error = %v", err) } - provider.client = server.Client() + p.client = server.Client() - events := make(chan domain.StreamEvent, 8) - err = provider.Chat(context.Background(), domain.ChatRequest{ + events := make(chan providertypes.StreamEvent, 8) + err = p.Chat(context.Background(), providertypes.ChatRequest{ Model: "gpt-5.4", - Messages: []domain.Message{ + Messages: []providertypes.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: "assistant", ToolCalls: []providertypes.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", - }, - }, - }, + Tools: []providertypes.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) @@ -390,32 +922,30 @@ func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { t.Fatal("expected streamed events") } - var ( - chunks []string - toolCallStartSeen bool - toolCallArgs strings.Builder - messageDone *domain.MessageDonePayload - ) + var chunks []string + var toolCallStartSeen bool + var toolCallArgs strings.Builder + var messageDone *providertypes.MessageDonePayload for _, event := range streamEvents { switch event.Type { - case domain.StreamEventTextDelta: + case providertypes.StreamEventTextDelta: chunks = append(chunks, requireTextDeltaPayload(t, event).Text) - case domain.StreamEventToolCallStart: + case providertypes.StreamEventToolCallStart: payload := requireToolCallStartPayload(t, event) toolCallStartSeen = true if payload.Index != 0 || payload.ID != "call_1" || payload.Name != "filesystem_edit" { t.Fatalf("unexpected tool_call_start payload: %+v", payload) } - case domain.StreamEventToolCallDelta: + case providertypes.StreamEventToolCallDelta: payload := requireToolCallDeltaPayload(t, event) if payload.Index != 0 || payload.ID != "call_1" { t.Fatalf("unexpected tool_call_delta payload: %+v", payload) } toolCallArgs.WriteString(payload.ArgumentsDelta) - case domain.StreamEventMessageDone: - payload := requireMessageDonePayload(t, event) - messageDone = &payload + case providertypes.StreamEventMessageDone: + p := requireMessageDonePayload(t, event) + messageDone = &p } } @@ -448,18 +978,8 @@ func TestProviderChatHTTPErrorResponses(t *testing.T) { 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", - }, + {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 { @@ -473,15 +993,13 @@ func TestProviderChatHTTPErrorResponses(t *testing.T) { })) defer server.Close() - provider, err := New(resolvedConfig(server.URL, config.OpenAIDefaultModel)) + p, err := New(resolvedConfig(server.URL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } - provider.client = server.Client() + p.client = server.Client() - err = provider.Chat(context.Background(), domain.ChatRequest{ - Model: config.OpenAIDefaultModel, - }, make(chan domain.StreamEvent, 1)) + err = p.Chat(context.Background(), providertypes.ChatRequest{Model: config.OpenAIDefaultModel}, make(chan providertypes.StreamEvent, 1)) if err == nil || !strings.Contains(err.Error(), tt.expectErr) { t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) } @@ -492,36 +1010,19 @@ func TestProviderChatHTTPErrorResponses(t *testing.T) { func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { t.Parallel() - provider, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } - payload, err := provider.buildRequest(domain.ChatRequest{ + payload, err := p.buildRequest(providertypes.ChatRequest{ SystemPrompt: "system prompt", - Messages: []domain.Message{ + Messages: []providertypes.Message{ {Role: "user", Content: "hello"}, - { - Role: "assistant", - ToolCalls: []domain.ToolCall{ - { - ID: "call_1", - Name: "filesystem_edit", - Arguments: `{"path":"main.go"}`, - }, - }, - }, + {Role: "assistant", ToolCalls: []providertypes.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", - }, - }, - }, + Tools: []providertypes.ToolSpec{{Name: "filesystem_edit", Description: "Edit file content", Schema: map[string]any{"type": "object"}}}, }) if err != nil { t.Fatalf("buildRequest() error = %v", err) @@ -553,7 +1054,7 @@ func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { func TestParseErrorAndEmitTextDelta(t *testing.T) { t.Parallel() - provider, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -564,46 +1065,33 @@ func TestParseErrorAndEmitTextDelta(t *testing.T) { 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", - }, + {"json error payload", "400 Bad Request", `{"error":{"message":"invalid request"}}`, "invalid request"}, + {"plain text fallback", "502 Bad Gateway", `gateway timeout`, "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) + resp := &http.Response{Status: tt.status, Body: ioNopCloser(tt.body)} + err := p.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) + eventCh := make(chan providertypes.StreamEvent, 1) if err := emitTextDelta(context.Background(), eventCh, "chunk"); err != nil { t.Fatalf("emitTextDelta() error = %v", err) } - if got := <-eventCh; got.Type != domain.StreamEventTextDelta || requireTextDeltaPayload(t, got).Text != "chunk" { + if got := <-eventCh; got.Type != providertypes.StreamEventTextDelta || requireTextDeltaPayload(t, got).Text != "chunk" { 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 { + if err := emitTextDelta(cancelledCtx, make(chan providertypes.StreamEvent), "chunk"); err == nil { t.Fatalf("expected cancellation error") } } @@ -611,19 +1099,34 @@ func TestParseErrorAndEmitTextDelta(t *testing.T) { func TestProviderConsumeStreamRejectsDirtyJSON(t *testing.T) { t.Parallel() - provider, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) 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)) + err = p.consumeStream(context.Background(), strings.NewReader("data: {not-json}\n\n"), make(chan providertypes.StreamEvent, 1)) if err == nil || !strings.Contains(err.Error(), "decode stream chunk") { t.Fatalf("expected dirty JSON decode error, got %v", err) } } -func drainStreamEvents(events <-chan domain.StreamEvent) []domain.StreamEvent { - drained := make([]domain.StreamEvent, 0) +// --- 辅助函数 --- + +func resolvedConfig(baseURL string, model string) config.ResolvedProviderConfig { + if strings.TrimSpace(baseURL) == "" { + baseURL = config.OpenAIDefaultBaseURL + } + if strings.TrimSpace(model) == "" { + model = config.OpenAIDefaultModel + } + return config.ResolvedProviderConfig{ + ProviderConfig: config.ProviderConfig{Name: DriverName, Driver: DriverName, BaseURL: baseURL, Model: model, APIKeyEnv: config.OpenAIDefaultAPIKeyEnv}, + APIKey: "test-key", + } +} + +func drainStreamEvents(events <-chan providertypes.StreamEvent) []providertypes.StreamEvent { + drained := make([]providertypes.StreamEvent, 0) for { select { case evt, ok := <-events: @@ -637,7 +1140,7 @@ func drainStreamEvents(events <-chan domain.StreamEvent) []domain.StreamEvent { } } -func requireTextDeltaPayload(t *testing.T, event domain.StreamEvent) domain.TextDeltaPayload { +func requireTextDeltaPayload(t *testing.T, event providertypes.StreamEvent) providertypes.TextDeltaPayload { t.Helper() payload, err := event.TextDeltaValue() if err != nil { @@ -646,7 +1149,7 @@ func requireTextDeltaPayload(t *testing.T, event domain.StreamEvent) domain.Text return payload } -func requireToolCallStartPayload(t *testing.T, event domain.StreamEvent) domain.ToolCallStartPayload { +func requireToolCallStartPayload(t *testing.T, event providertypes.StreamEvent) providertypes.ToolCallStartPayload { t.Helper() payload, err := event.ToolCallStartValue() if err != nil { @@ -655,7 +1158,7 @@ func requireToolCallStartPayload(t *testing.T, event domain.StreamEvent) domain. return payload } -func requireToolCallDeltaPayload(t *testing.T, event domain.StreamEvent) domain.ToolCallDeltaPayload { +func requireToolCallDeltaPayload(t *testing.T, event providertypes.StreamEvent) providertypes.ToolCallDeltaPayload { t.Helper() payload, err := event.ToolCallDeltaValue() if err != nil { @@ -664,7 +1167,7 @@ func requireToolCallDeltaPayload(t *testing.T, event domain.StreamEvent) domain. return payload } -func requireMessageDonePayload(t *testing.T, event domain.StreamEvent) domain.MessageDonePayload { +func requireMessageDonePayload(t *testing.T, event providertypes.StreamEvent) providertypes.MessageDonePayload { t.Helper() payload, err := event.MessageDoneValue() if err != nil { @@ -674,8 +1177,8 @@ func requireMessageDonePayload(t *testing.T, event domain.StreamEvent) domain.Me } func containsToolRoleMessage(messages []openAIMessage, toolCallID string, content string) bool { - for _, message := range messages { - if message.Role == "tool" && message.ToolCallID == toolCallID && message.Content == content { + for _, m := range messages { + if m.Role == "tool" && m.ToolCallID == toolCallID && m.Content == content { return true } } @@ -691,37 +1194,90 @@ func writeSSEChunk(t *testing.T, w http.ResponseWriter, payload any) { 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() + if f, ok := w.(http.Flusher); ok { + f.Flush() } } -func ioNopCloser(body string) *readCloser { - return &readCloser{Reader: strings.NewReader(body)} -} +func ioNopCloser(body string) *readCloser { return &readCloser{Reader: strings.NewReader(body)} } + +type readCloser struct{ *strings.Reader } + +func (r *readCloser) Close() error { return nil } + +// --- 错误包装测试 --- + +func TestConsumeStream_WrapsNonEOFAsInterrupted(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") -type readCloser struct { - *strings.Reader + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + errReader := &errReader{err: io.ErrClosedPipe} + err = p.consumeStream(context.Background(), errReader, make(chan providertypes.StreamEvent, 1)) + if err == nil { + t.Fatal("expected error for broken reader") + } + if !errors.Is(err, provider.ErrStreamInterrupted) { + t.Fatalf("expected ErrStreamInterrupted wrapping, got: %v", err) + } } -func (r *readCloser) Close() error { - return nil +func TestConsumeStream_FlushesPendingDataOnNonEOFError(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + sseData := `data: {"id":"a","choices":[{"delta":{"content":"hello"},"finish_reason":""}]} +` + body := io.MultiReader(strings.NewReader(sseData), &errReader{err: io.ErrClosedPipe}) + events := make(chan providertypes.StreamEvent, 10) + + err = p.consumeStream(context.Background(), body, events) + if err == nil { + t.Fatal("expected error for broken reader") + } + if !errors.Is(err, provider.ErrStreamInterrupted) { + t.Fatalf("expected ErrStreamInterrupted, got: %v", err) + } + + drained := drainStreamEvents(events) + var foundText bool + for _, evt := range drained { + if evt.Type == providertypes.StreamEventTextDelta { + foundText = true + } + } + if !foundText { + t.Fatal("expected text_delta event from flushed pending data") + } } -// --- emitToolCallStart 边界测试 --- +type errReader struct{ err error } + +func (e *errReader) Read(_ []byte) (int, error) { return 0, e.err } + +type failingReadCloser struct{ err error } + +func (f *failingReadCloser) Read(_ []byte) (int, error) { return 0, f.err } +func (f *failingReadCloser) Close() error { return f.err } + +// --- emitToolCallStart 和 mergeToolCallDelta 保留测试 --- func TestEmitToolCallStartGuards(t *testing.T) { t.Parallel() - ctx := context.Background() - // nil events 守卫 if err := emitToolCallStart(ctx, nil, 0, "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) + events := make(chan providertypes.StreamEvent, 1) if err := emitToolCallStart(ctx, events, 0, "call-1", ""); err != nil { t.Fatalf("expected empty name guard to return nil, got %v", err) } @@ -731,55 +1287,41 @@ func TestEmitToolCallStartGuards(t *testing.T) { default: } - // 正常发送 if err := emitToolCallStart(ctx, events, 2, "call-1", "filesystem_edit"); err != nil { t.Fatalf("emitToolCallStart() error = %v", err) } got := <-events payload := requireToolCallStartPayload(t, got) - if got.Type != domain.StreamEventToolCallStart || payload.Name != "filesystem_edit" || payload.ID != "call-1" || payload.Index != 2 { + if got.Type != providertypes.StreamEventToolCallStart || payload.Name != "filesystem_edit" || payload.ID != "call-1" || payload.Index != 2 { t.Fatalf("unexpected event: %+v", got) } - // context 取消 cancelledCtx, cancel := context.WithCancel(context.Background()) cancel() - if err := emitToolCallStart(cancelledCtx, make(chan domain.StreamEvent), 0, "call-1", "filesystem_edit"); err == nil { - t.Fatalf("expected cancellation error") + if err := emitToolCallStart(cancelledCtx, make(chan providertypes.StreamEvent), 0, "call-1", "filesystem_edit"); err == nil { + t.Fatal("expected cancellation error") } } func TestMergeToolCallDeltaEmitsStartWhenNameArrivesLater(t *testing.T) { t.Parallel() - events := make(chan domain.StreamEvent, 4) - toolCalls := make(map[int]*domain.ToolCall) - - if err := mergeToolCallDelta(context.Background(), events, toolCalls, toolCallDelta{ - Index: 0, - ID: "call_late_name", - }); err != nil { - t.Fatalf("mergeToolCallDelta() first delta error = %v", err) - } + events := make(chan providertypes.StreamEvent, 4) + toolCalls := make(map[int]*providertypes.ToolCall) + mergeToolCallDelta(context.Background(), events, toolCalls, toolCallDelta{Index: 0, ID: "call_late_name"}) select { case evt := <-events: t.Fatalf("expected no event before tool name arrives, got %+v", evt) default: } - if err := mergeToolCallDelta(context.Background(), events, toolCalls, toolCallDelta{ - Index: 0, - Function: openAIFunctionCall{ - Name: "filesystem_edit", - Arguments: `{"path":"main.go"}`, - }, - }); err != nil { - t.Fatalf("mergeToolCallDelta() late-name delta error = %v", err) - } + mergeToolCallDelta(context.Background(), events, toolCalls, toolCallDelta{ + Index: 0, Function: openAIFunctionCall{Name: "filesystem_edit", Arguments: `{"path":"main.go"}`}, + }) start := <-events - if start.Type != domain.StreamEventToolCallStart { + if start.Type != providertypes.StreamEventToolCallStart { t.Fatalf("expected tool_call_start event, got %+v", start) } startPayload := requireToolCallStartPayload(t, start) @@ -788,7 +1330,7 @@ func TestMergeToolCallDeltaEmitsStartWhenNameArrivesLater(t *testing.T) { } delta := <-events - if delta.Type != domain.StreamEventToolCallDelta { + if delta.Type != providertypes.StreamEventToolCallDelta { t.Fatalf("expected tool_call_delta event, got %+v", delta) } deltaPayload := requireToolCallDeltaPayload(t, delta) @@ -810,43 +1352,21 @@ func TestProviderChatEmitsToolCallStartEvent(t *testing.T) { 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": `{}`, - }, - }, - }, - }, - }, - }, - }) + 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, config.OpenAIDefaultModel)) + p, err := New(resolvedConfig(server.URL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } - provider.client = server.Client() + p.client = server.Client() - events := make(chan domain.StreamEvent, 8) - err = provider.Chat(context.Background(), domain.ChatRequest{ - Model: config.OpenAIDefaultModel, - Messages: []domain.Message{{Role: "user", Content: "edit"}}, - Tools: []domain.ToolSpec{ - {Name: "filesystem_edit", Description: "edit", Schema: map[string]any{"type": "object"}}, - }, + events := make(chan providertypes.StreamEvent, 8) + err = p.Chat(context.Background(), providertypes.ChatRequest{ + Model: config.OpenAIDefaultModel, Messages: []providertypes.Message{{Role: "user", Content: "edit"}}, + Tools: []providertypes.ToolSpec{{Name: "filesystem_edit", Description: "edit", Schema: map[string]any{"type": "object"}}}, }, events) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -854,7 +1374,7 @@ func TestProviderChatEmitsToolCallStartEvent(t *testing.T) { var foundToolCallStart bool for _, evt := range drainStreamEvents(events) { - if evt.Type == domain.StreamEventToolCallStart { + if evt.Type == providertypes.StreamEventToolCallStart { foundToolCallStart = true payload := requireToolCallStartPayload(t, evt) if payload.Name != "filesystem_edit" { @@ -870,129 +1390,51 @@ func TestProviderChatEmitsToolCallStartEvent(t *testing.T) { } } -// TestProviderChatEmitsFullEventStream 测试完整的事件流(包括 tool_call_delta 和 message_done)。 func TestProviderChatEmitsFullEventStream(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") - - // 发送文本 delta - writeSSEChunk(t, w, map[string]any{ - "choices": []map[string]any{ - { - "index": 0, - "delta": map[string]any{ - "content": "Hello", - }, - }, - }, - }) - - // 发送 tool call start 和 delta - 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_1", - "type": "function", - "function": map[string]any{ - "name": "filesystem_edit", - "arguments": `{"path":"a.`, - }, - }, - }, - }, - }, - }, - }) - - // 发送 tool call delta(参数增量) - writeSSEChunk(t, w, map[string]any{ - "choices": []map[string]any{ - { - "index": 0, - "delta": map[string]any{ - "tool_calls": []map[string]any{ - { - "index": 0, - "function": map[string]any{ - "arguments": `go"}`, - }, - }, - }, - }, - }, - }, - }) - - // 发送 usage 和 finish_reason - writeSSEChunk(t, w, map[string]any{ - "choices": []map[string]any{ - { - "index": 0, - "finish_reason": "tool_calls", - }, - }, - "usage": map[string]any{ - "prompt_tokens": 100, - "completion_tokens": 50, - "total_tokens": 150, - }, - }) - + 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_tool_1", "type": "function", "function": map[string]any{"name": "filesystem_edit", "arguments": `{"path":"a.go"}`}}}}}}}) + writeSSEChunk(t, w, map[string]any{"choices": []map[string]any{{"index": 0, "finish_reason": "tool_calls"}}, "usage": map[string]any{"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}}) _, _ = w.Write([]byte("data: [DONE]\n\n")) })) defer server.Close() - provider, err := New(resolvedConfig(server.URL, config.OpenAIDefaultModel)) + p, err := New(resolvedConfig(server.URL, config.OpenAIDefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } - provider.client = server.Client() + p.client = server.Client() - events := make(chan domain.StreamEvent, 16) - err = provider.Chat(context.Background(), domain.ChatRequest{ - Model: config.OpenAIDefaultModel, - Messages: []domain.Message{{Role: "user", Content: "test"}}, - }, events) + events := make(chan providertypes.StreamEvent, 16) + err = p.Chat(context.Background(), providertypes.ChatRequest{Model: config.OpenAIDefaultModel, Messages: []providertypes.Message{{Role: "user", Content: "test"}}}, events) if err != nil { t.Fatalf("Chat() error = %v", err) } - var ( - foundTextDelta bool - foundToolCallStart bool - foundToolCallDelta bool - foundMessageDone bool - toolCallDeltaContent string - messageDonePayload *domain.MessageDonePayload - ) + var foundTextDelta, foundToolCallStart, foundToolCallDelta, foundMessageDone bool + var toolCallDeltaContent string + var messageDonePayload *providertypes.MessageDonePayload for _, evt := range drainStreamEvents(events) { switch evt.Type { - case domain.StreamEventTextDelta: + case providertypes.StreamEventTextDelta: foundTextDelta = true - case domain.StreamEventToolCallStart: + case providertypes.StreamEventToolCallStart: foundToolCallStart = true - payload := requireToolCallStartPayload(t, evt) - if payload.Name != "filesystem_edit" { - t.Fatalf("expected ToolName %q, got %q", "filesystem_edit", payload.Name) - } - if payload.Index != 0 { - t.Fatalf("expected ToolCallIndex %d for tool_call_start, got %d", 0, payload.Index) + p := requireToolCallStartPayload(t, evt) + if p.Name != "filesystem_edit" { + t.Fatalf("expected ToolName %q, got %q", "filesystem_edit", p.Name) } - case domain.StreamEventToolCallDelta: + case providertypes.StreamEventToolCallDelta: foundToolCallDelta = true toolCallDeltaContent += requireToolCallDeltaPayload(t, evt).ArgumentsDelta - case domain.StreamEventMessageDone: + case providertypes.StreamEventMessageDone: foundMessageDone = true - payload := requireMessageDonePayload(t, evt) - messageDonePayload = &payload + p := requireMessageDonePayload(t, evt) + messageDonePayload = &p } } @@ -1008,14 +1450,9 @@ func TestProviderChatEmitsFullEventStream(t *testing.T) { if !foundMessageDone { t.Fatal("expected StreamEventMessageDone event") } - - // 验证 tool_call_delta 内容被正确累加 - expectedDelta := `{"path":"a.go"}` - if toolCallDeltaContent != expectedDelta { - t.Fatalf("expected tool call delta content %q, got %q", expectedDelta, toolCallDeltaContent) + if toolCallDeltaContent != `{"path":"a.go"}` { + t.Fatalf("expected tool call delta content %q, got %q", `{"path":"a.go"}`, toolCallDeltaContent) } - - // 验证 message_done 事件包含正确的字段 if messageDonePayload == nil { t.Fatal("message_done event is nil") } @@ -1029,75 +1466,3 @@ func TestProviderChatEmitsFullEventStream(t *testing.T) { t.Fatalf("expected TotalTokens %d, got %d", 150, messageDonePayload.Usage.TotalTokens) } } - -// --- 辅助方法测试 --- - -// --- consumeStream 错误包装测试 --- - -func TestConsumeStream_WrapsNonEOFAsInterrupted(t *testing.T) { - t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - - p, err := New(resolvedConfig("", "")) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - // 使用一个会触发读取错误的 source(模拟网络断开) - errReader := &errReader{err: io.ErrClosedPipe} - - err = p.consumeStream(context.Background(), errReader, make(chan domain.StreamEvent, 1)) - if err == nil { - t.Fatal("expected error for broken reader") - } - if !errors.Is(err, domain.ErrStreamInterrupted) { - t.Fatalf("expected ErrStreamInterrupted wrapping, got: %v", err) - } -} - -// TestConsumeStream_FlushesPendingDataOnNonEOFError 验证非 EOF 读取错误发生前, -// 已缓冲但尚未刷新的 data: 行仍会被处理(不会因中断而丢失)。 -func TestConsumeStream_FlushesPendingDataOnNonEOFError(t *testing.T) { - t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") - - p, err := New(resolvedConfig("", "")) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - // 构造一段 SSE 数据:包含一个有效 data 行,但紧跟一个错误而非空行。 - // 这模拟了流中断前最后一帧数据还没来得及被空行触发刷新的场景。 - sseData := `data: {"id":"a","object":"chat.completion.chunk","choices":[{"delta":{"content":"hello"},"finish_reason":""}]} -` // 注意:这里有换行符,但由于紧接着是 error,不会被空行刷新 - body := io.MultiReader(strings.NewReader(sseData), &errReader{err: io.ErrClosedPipe}) - - events := make(chan domain.StreamEvent, 10) - - err = p.consumeStream(context.Background(), body, events) - if err == nil { - t.Fatal("expected error for broken reader") - } - if !errors.Is(err, domain.ErrStreamInterrupted) { - t.Fatalf("expected ErrStreamInterrupted, got: %v", err) - } - - // 关键断言:中断前的 data 行必须已被刷新处理,应有 text_delta 事件。 - drained := drainStreamEvents(events) - var foundText bool - for _, evt := range drained { - if evt.Type == domain.StreamEventTextDelta { - foundText = true - } - } - if !foundText { - t.Fatal("expected text_delta event from flushed pending data") - } -} - -// errReader 是一个每次 ReadLine 都返回指定错误的测试辅助类型。 -type errReader struct { - err error -} - -func (e *errReader) Read(p []byte) (int, error) { - return 0, e.err -} diff --git a/internal/provider/openai/provider.go b/internal/provider/openai/provider.go new file mode 100644 index 00000000..f6db0d37 --- /dev/null +++ b/internal/provider/openai/provider.go @@ -0,0 +1,121 @@ +package openai + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" +) + +// Provider 封装 OpenAI 兼容 API 的客户端配置和 HTTP 连接。 +type Provider struct { + cfg config.ResolvedProviderConfig + client *http.Client +} + +// buildOptions 控制构造行为,用于注入自定义 Transport 等选项。 +type buildOptions struct { + transport http.RoundTripper +} + +// buildOption 是 New() 的函数式配置选项。 +type buildOption func(*buildOptions) + +// withTransport 注入自定义 HTTP Transport(如 RetryTransport)。 +func withTransport(rt http.RoundTripper) buildOption { + return func(o *buildOptions) { + o.transport = rt + } +} + +// New 创建 OpenAI provider 实例。cfg 必须 Validate 通过且包含有效 API Key。 +func New(cfg config.ResolvedProviderConfig, opts ...buildOption) (*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") + } + + o := &buildOptions{ + transport: http.DefaultTransport, + } + for _, apply := range opts { + apply(o) + } + + return &Provider{ + cfg: cfg, + client: &http.Client{ + Timeout: 90 * time.Second, + Transport: o.transport, + }, + }, nil +} + +// DiscoverModels 通过 /models 端点查询可用模型列表。 +func (p *Provider) DiscoverModels(ctx context.Context) ([]config.ModelDescriptor, error) { + rawModels, err := p.fetchModels(ctx) + if err != nil { + return nil, err + } + + descriptors := make([]config.ModelDescriptor, 0, len(rawModels)) + for _, raw := range rawModels { + descriptor, ok := config.DescriptorFromRawModel(raw) + if !ok { + continue + } + descriptors = append(descriptors, descriptor) + } + return config.MergeModelDescriptors(descriptors), nil +} + +// Chat 发起 SSE 流式对话请求。 +// 流中途断连或协议错误时直接返回错误,由上层调用方决定重试策略。 +func (p *Provider) Chat(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + payload, err := p.buildRequest(req) + if err != nil { + return err + } + + body, err := json.Marshal(payload) + if err != nil { + return 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 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 fmt.Errorf("openai provider: send request: %w", err) + } + 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 p.parseError(resp) + } + + return p.consumeStream(ctx, resp.Body, events) +} diff --git a/internal/provider/openai/request.go b/internal/provider/openai/request.go new file mode 100644 index 00000000..02f5398b --- /dev/null +++ b/internal/provider/openai/request.go @@ -0,0 +1,105 @@ +package openai + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" +) + +// buildRequest 将 provider.ChatRequest 转换为 OpenAI API 请求结构。 +// 模型优先取 req.Model,其次使用配置中的默认模型。 +func (p *Provider) buildRequest(req providertypes.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: providertypes.RoleSystem, + 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 +} + +// toOpenAIMessage 将通用 Message 转换为 OpenAI 协议消息格式。 +func toOpenAIMessage(message providertypes.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 +} + +// parseError 解析 HTTP 错误响应并包装为 ProviderError。 +func (p *Provider) parseError(resp *http.Response) error { + data, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return provider.NewProviderErrorFromStatus(resp.StatusCode, + fmt.Sprintf("openai provider: read error response: %v", readErr)) + } + + var parsed openAIErrorResponse + if err := json.Unmarshal(data, &parsed); err == nil && strings.TrimSpace(parsed.Error.Message) != "" { + return provider.NewProviderErrorFromStatus(resp.StatusCode, parsed.Error.Message) + } + + bodyText := strings.TrimSpace(string(data)) + if bodyText == "" { + return provider.NewProviderErrorFromStatus(resp.StatusCode, resp.Status) + } + + return provider.NewProviderErrorFromStatus(resp.StatusCode, bodyText) +} diff --git a/internal/provider/openai/response.go b/internal/provider/openai/response.go new file mode 100644 index 00000000..02b7b9b5 --- /dev/null +++ b/internal/provider/openai/response.go @@ -0,0 +1,135 @@ +package openai + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" +) + +// consumeStream 消费 SSE 响应流,使用有界读取器防止缓冲区溢出。 +func (p *Provider) consumeStream( + ctx context.Context, + body io.Reader, + events chan<- providertypes.StreamEvent, +) error { + reader := newBoundedSSEReader(body) + + var ( + finishReason string + usage providertypes.Usage + done bool + toolCalls = make(map[int]*providertypes.ToolCall) + ) + + dataLines := make([]string, 0, 4) + + // processChunk 解析单个 SSE data payload,发送事件。 + processChunk := func(payload string) error { + 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) + } + + extractStreamUsage(&usage, chunk.Usage) + + for _, choice := range chunk.Choices { + if choice.FinishReason != "" { + finishReason = choice.FinishReason + } + if choice.Delta.Content != "" { + if err := emitTextDelta(ctx, events, choice.Delta.Content); err != nil { + return err + } + } + for _, delta := range choice.Delta.ToolCalls { + if err := mergeToolCallDelta(ctx, events, toolCalls, delta); err != nil { + return err + } + } + } + return nil + } + + // finishStream 统一的流结束处理:发送 message_done 事件。 + finishStream := func() error { + return emitMessageDone(ctx, events, finishReason, &usage) + } + + flushPendingData := func() error { + defer func() { dataLines = dataLines[:0] }() + return flushDataLines(dataLines, processChunk) + } + + for { + line, err := reader.ReadLine() + + if err != nil && !errors.Is(err, io.EOF) { + // 非 EOF 的读取错误:先刷新缓冲的 data 行,再包装为流中断, + // 避免中断前最后一段数据丢失。 + if flushErr := flushPendingData(); flushErr != nil { + return flushErr + } + return fmt.Errorf("%w: %w", provider.ErrStreamInterrupted, err) + } + + trimmed := line + + switch { + case strings.HasPrefix(trimmed, "data:"): + data := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + // data: [DONE] 需要立即处理:先刷新已缓冲的 data 行,再标记结束, + // 避免与前面的合法 JSON 拼接后导致 json.Unmarshal 失败。 + if data == "[DONE]" { + if flushErr := flushPendingData(); flushErr != nil { + return flushErr + } + done = true + } else { + dataLines = append(dataLines, data) + } + case trimmed == "": + if flushErr := flushPendingData(); flushErr != nil { + return flushErr + } + if done { + return finishStream() + } + case strings.HasPrefix(trimmed, ":"): + // SSE comment/heartbeat; ignore. + } + + if errors.Is(err, io.EOF) { + if flushErr := flushPendingData(); flushErr != nil { + return flushErr + } + return finishStream() + } + } +} + +// extractStreamUsage 从 OpenAI usage 响应提取并覆盖累积的 token 统计。 +func extractStreamUsage(usage *providertypes.Usage, raw *openAIUsage) { + if raw == nil { + return + } + *usage = providertypes.Usage{ + InputTokens: raw.PromptTokens, + OutputTokens: raw.CompletionTokens, + TotalTokens: raw.TotalTokens, + } +} diff --git a/internal/provider/openai/toolcall.go b/internal/provider/openai/toolcall.go new file mode 100644 index 00000000..339d86c9 --- /dev/null +++ b/internal/provider/openai/toolcall.go @@ -0,0 +1,43 @@ +package openai + +import ( + "context" + "strings" + + providertypes "neo-code/internal/provider/types" +) + +// mergeToolCallDelta 将单个 tool call delta 累积到 toolCalls map 中。 +// 首次发现带名称的 delta 时发送 tool_call_start 事件; +// 每次收到 arguments 增量时发送 tool_call_delta 事件。 +func mergeToolCallDelta(ctx context.Context, events chan<- providertypes.StreamEvent, toolCalls map[int]*providertypes.ToolCall, delta toolCallDelta) error { + call, exists := toolCalls[delta.Index] + if !exists { + call = &providertypes.ToolCall{} + toolCalls[delta.Index] = call + } + + hadName := strings.TrimSpace(call.Name) != "" + + if id := strings.TrimSpace(delta.ID); id != "" { + call.ID = id + } + if name := strings.TrimSpace(delta.Function.Name); name != "" { + call.Name = name + } + + if !hadName && strings.TrimSpace(call.Name) != "" { + if err := emitToolCallStart(ctx, events, delta.Index, call.ID, call.Name); err != nil { + return err + } + } + + // 发送参数增量事件(同一 chunk 可能同时携带 name 和 arguments) + if args := delta.Function.Arguments; args != "" { + call.Arguments += args + if err := emitToolCallDelta(ctx, events, delta.Index, call.ID, args); err != nil { + return err + } + } + return nil +} diff --git a/internal/provider/openai/types.go b/internal/provider/openai/types.go new file mode 100644 index 00000000..f943e1e3 --- /dev/null +++ b/internal/provider/openai/types.go @@ -0,0 +1,90 @@ +package openai + +// 以下类型定义了 OpenAI Chat Completions API 的请求和响应结构体, +// 仅在 openai 子包内部使用,不对外暴露。 + +// chatCompletionRequest 表示 /chat/completions 端点的请求体。 +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"` +} + +// openAIMessage 表示 OpenAI 协议中的消息格式。 +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"` +} + +// openAIToolDefinition 表示工具定义的 OpenAI 格式。 +type openAIToolDefinition struct { + Type string `json:"type"` + Function openAIFunctionDefinition `json:"function"` +} + +// openAIFunctionDefinition 表示函数描述的 OpenAI 格式。 +type openAIFunctionDefinition struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +// openAIToolCall 表示响应中工具调用的 OpenAI 格式。 +type openAIToolCall struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function openAIFunctionCall `json:"function"` +} + +// openAIFunctionCall 表示函数调用参数的 OpenAI 格式。 +type openAIFunctionCall struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +// chatCompletionChunk 表示 SSE 流式响应中的单个 chunk(内部使用,非导出)。 +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"` +} + +// chunkDelta 表示流式 chunk 中的增量内容。 +type chunkDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []toolCallDelta `json:"tool_calls,omitempty"` +} + +// toolCallDelta 表示流式 tool call 增量。 +type toolCallDelta struct { + Index int `json:"index"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function openAIFunctionCall `json:"function"` +} + +// openAIUsage 表示 token 使用统计。 +type openAIUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// openAIErrorResponse 表示 API 错误响应。 +type openAIErrorResponse struct { + Error struct { + Message string `json:"message"` + Code string `json:"code,omitempty"` + } `json:"error"` +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 91a99b52..f8aff036 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1,7 +1,12 @@ package provider -import "context" +import ( + "context" + "neo-code/internal/provider/types" +) + +// Provider 定义模型对话能力,通过 channel 推送流式事件给上层消费。 type Provider interface { - Chat(ctx context.Context, req ChatRequest, events chan<- StreamEvent) error + Chat(ctx context.Context, req types.ChatRequest, events chan<- types.StreamEvent) error } diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index df57c9da..5ddc5b31 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -8,11 +8,12 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" "neo-code/internal/provider/openai" + providertypes "neo-code/internal/provider/types" ) type stubProvider struct{} -func (stubProvider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error { +func (stubProvider) Chat(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { return nil } diff --git a/internal/provider/types.go b/internal/provider/types/event.go similarity index 76% rename from internal/provider/types.go rename to internal/provider/types/event.go index 1f689c12..72df4bc1 100644 --- a/internal/provider/types.go +++ b/internal/provider/types/event.go @@ -1,56 +1,7 @@ -package provider +package types import "fmt" -const ( - // RoleSystem 标识系统消息。 - RoleSystem = "system" - // RoleUser 标识用户消息。 - RoleUser = "user" - // RoleAssistant 标识助手消息。 - RoleAssistant = "assistant" - // RoleTool 标识工具结果消息。 - RoleTool = "tool" -) - -// Message 表示对话中的单条消息。 -type Message struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` - IsError bool `json:"is_error,omitempty"` -} - -// ToolCall 表示模型发起的工具调用请求。 -type ToolCall struct { - ID string `json:"id"` - Name string `json:"name"` - Arguments string `json:"arguments"` -} - -// ToolSpec 表示暴露给模型的可调用工具描述。 -type ToolSpec struct { - Name string `json:"name"` - Description string `json:"description"` - Schema map[string]any `json:"schema"` -} - -// ChatRequest 是 provider.Chat() 的请求参数。 -type ChatRequest struct { - Model string `json:"model"` - SystemPrompt string `json:"system_prompt"` - Messages []Message `json:"messages"` - Tools []ToolSpec `json:"tools,omitempty"` -} - -// Usage 记录本次请求的 token 使用统计。 -type Usage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - TotalTokens int `json:"total_tokens"` -} - // StreamEventType 定义流式事件类型。 type StreamEventType string diff --git a/internal/provider/types/message.go b/internal/provider/types/message.go new file mode 100644 index 00000000..3758c99f --- /dev/null +++ b/internal/provider/types/message.go @@ -0,0 +1,36 @@ +package types + +// RoleSystem 标识系统消息。 +const RoleSystem = "system" + +// RoleUser 标识用户消息。 +const RoleUser = "user" + +// RoleAssistant 标识助手消息。 +const RoleAssistant = "assistant" + +// RoleTool 标识工具结果消息。 +const RoleTool = "tool" + +// Message 表示对话中的单条消息。 +type Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + IsError bool `json:"is_error,omitempty"` +} + +// ToolCall 表示模型发起的工具调用请求。 +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// ToolSpec 表示暴露给模型的可调用工具描述。 +type ToolSpec struct { + Name string `json:"name"` + Description string `json:"description"` + Schema map[string]any `json:"schema"` +} diff --git a/internal/provider/types/request.go b/internal/provider/types/request.go new file mode 100644 index 00000000..1171f6b0 --- /dev/null +++ b/internal/provider/types/request.go @@ -0,0 +1,16 @@ +package types + +// ChatRequest 是 provider.Chat() 的请求参数。 +type ChatRequest struct { + Model string `json:"model"` + SystemPrompt string `json:"system_prompt"` + Messages []Message `json:"messages"` + Tools []ToolSpec `json:"tools,omitempty"` +} + +// Usage 记录本次请求的 token 使用统计。 +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` +} diff --git a/internal/provider/types_test.go b/internal/provider/types/types_test.go similarity index 99% rename from internal/provider/types_test.go rename to internal/provider/types/types_test.go index 5c822ee8..958ebb16 100644 --- a/internal/provider/types_test.go +++ b/internal/provider/types/types_test.go @@ -1,4 +1,4 @@ -package provider +package types import ( "encoding/json" diff --git a/internal/runtime/compact.go b/internal/runtime/compact.go index 4939ca62..9798d250 100644 --- a/internal/runtime/compact.go +++ b/internal/runtime/compact.go @@ -8,7 +8,7 @@ import ( "neo-code/internal/config" contextcompact "neo-code/internal/context/compact" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) // CompactInput 描述一次手动 compact 请求所需的最小输入。 @@ -103,7 +103,7 @@ func (s *Service) runCompactForSession( } } - originalMessages := append([]provider.Message(nil), session.Messages...) + originalMessages := append([]providertypes.Message(nil), session.Messages...) s.emit(ctx, EventCompactStart, runID, session.ID, string(contextcompact.ModeManual)) result, err := runner.Run(ctx, contextcompact.Input{ @@ -125,7 +125,7 @@ func (s *Service) runCompactForSession( } if result.Applied { - session.Messages = append([]provider.Message(nil), result.Messages...) + session.Messages = append([]providertypes.Message(nil), result.Messages...) session.UpdatedAt = time.Now() if err := s.sessionStore.Save(ctx, &session); err != nil { s.emit(ctx, EventCompactError, runID, session.ID, CompactErrorPayload{ diff --git a/internal/runtime/compact_generator.go b/internal/runtime/compact_generator.go index 20be11a1..af62c13a 100644 --- a/internal/runtime/compact_generator.go +++ b/internal/runtime/compact_generator.go @@ -8,7 +8,7 @@ import ( "neo-code/internal/config" agentcontext "neo-code/internal/context" contextcompact "neo-code/internal/context/compact" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) type compactSummaryGenerator struct { @@ -58,7 +58,7 @@ func (g *compactSummaryGenerator) Generate(ctx context.Context, input contextcom } // 使用流式事件通道收集 compact 摘要响应。 - streamEvents := make(chan provider.StreamEvent, 32) + streamEvents := make(chan providertypes.StreamEvent, 32) streamDone := make(chan error, 1) acc := newStreamAccumulator() @@ -84,11 +84,11 @@ func (g *compactSummaryGenerator) Generate(ctx context.Context, input contextcom } }() - err = modelProvider.Chat(ctx, provider.ChatRequest{ + err = modelProvider.Chat(ctx, providertypes.ChatRequest{ Model: g.model, SystemPrompt: prompt.SystemPrompt, - Messages: []provider.Message{{ - Role: provider.RoleUser, + Messages: []providertypes.Message{{ + Role: providertypes.RoleUser, Content: prompt.UserPrompt, }}, }, streamEvents) diff --git a/internal/runtime/compact_generator_test.go b/internal/runtime/compact_generator_test.go index e82c625f..00a96ec9 100644 --- a/internal/runtime/compact_generator_test.go +++ b/internal/runtime/compact_generator_test.go @@ -8,7 +8,7 @@ import ( "neo-code/internal/config" contextcompact "neo-code/internal/context/compact" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) func TestCompactSummaryGeneratorBuildsProviderRequestWithoutTools(t *testing.T) { @@ -21,8 +21,8 @@ func TestCompactSummaryGeneratorBuildsProviderRequestWithoutTools(t *testing.T) } scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ - {provider.NewTextDeltaStreamEvent(strings.Join([]string{ + streams: [][]providertypes.StreamEvent{ + {providertypes.NewTextDeltaStreamEvent(strings.Join([]string{ "[compact_summary]", "done:", "- Completed the historical task and kept the final result.", @@ -46,17 +46,17 @@ func TestCompactSummaryGeneratorBuildsProviderRequestWithoutTools(t *testing.T) summary, err := generator.Generate(context.Background(), contextcompact.SummaryInput{ Mode: contextcompact.ModeManual, - ArchivedMessages: []provider.Message{ - {Role: provider.RoleUser, Content: "legacy request"}, + ArchivedMessages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "legacy request"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, }, }, }, - RetainedMessages: []provider.Message{ - {Role: provider.RoleAssistant, Content: "recent answer"}, + RetainedMessages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "recent answer"}, }, ArchivedMessageCount: 2, Config: manager.Get().Context.Compact, @@ -87,7 +87,7 @@ func TestCompactSummaryGeneratorBuildsProviderRequestWithoutTools(t *testing.T) if !strings.Contains(req.SystemPrompt, "[compact_summary]") { t.Fatalf("expected compact system prompt, got %q", req.SystemPrompt) } - if len(req.Messages) != 1 || req.Messages[0].Role != provider.RoleUser { + if len(req.Messages) != 1 || req.Messages[0].Role != providertypes.RoleUser { t.Fatalf("expected a single user prompt, got %+v", req.Messages) } if !strings.Contains(req.Messages[0].Content, "") { @@ -114,10 +114,10 @@ func TestCompactSummaryGeneratorRejectsToolCalls(t *testing.T) { } scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ + streams: [][]providertypes.StreamEvent{ { - provider.NewToolCallStartStreamEvent(0, "call-1", "filesystem_read_file"), - provider.NewToolCallDeltaStreamEvent(0, "call-1", "{}"), + providertypes.NewToolCallStartStreamEvent(0, "call-1", "filesystem_read_file"), + providertypes.NewToolCallDeltaStreamEvent(0, "call-1", "{}"), }, }, } @@ -125,8 +125,8 @@ func TestCompactSummaryGeneratorRejectsToolCalls(t *testing.T) { _, err = generator.Generate(context.Background(), contextcompact.SummaryInput{ Mode: contextcompact.ModeManual, - ArchivedMessages: []provider.Message{ - {Role: provider.RoleUser, Content: "legacy request"}, + ArchivedMessages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "legacy request"}, }, Config: manager.Get().Context.Compact, }) @@ -145,9 +145,9 @@ func TestCompactSummaryGeneratorRejectsMalformedStreamEvent(t *testing.T) { } scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ + streams: [][]providertypes.StreamEvent{ { - {Type: provider.StreamEventTextDelta}, + {Type: providertypes.StreamEventTextDelta}, }, }, } @@ -171,12 +171,12 @@ func TestCompactSummaryGeneratorMalformedStreamEventDoesNotDeadlock(t *testing.T t.Fatalf("resolve provider: %v", err) } - stream := []provider.StreamEvent{{Type: provider.StreamEventTextDelta}} + stream := []providertypes.StreamEvent{{Type: providertypes.StreamEventTextDelta}} for i := 0; i < 40; i++ { - stream = append(stream, provider.NewTextDeltaStreamEvent("ignored")) + stream = append(stream, providertypes.NewTextDeltaStreamEvent("ignored")) } scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{stream}, + streams: [][]providertypes.StreamEvent{stream}, } generator := newCompactSummaryGenerator(&scriptedProviderFactory{provider: scripted}, resolvedProvider, "session-model") diff --git a/internal/runtime/permission.go b/internal/runtime/permission.go index 6782e9be..0ffe62ee 100644 --- a/internal/runtime/permission.go +++ b/internal/runtime/permission.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" "neo-code/internal/security" "neo-code/internal/tools" ) @@ -31,7 +31,7 @@ const ( type permissionExecutionInput struct { RunID string SessionID string - Call provider.ToolCall + Call providertypes.ToolCall Workdir string ToolTimeout time.Duration } @@ -40,7 +40,7 @@ type pendingPermissionRequest struct { RequestID string RunID string SessionID string - Call provider.ToolCall + Call providertypes.ToolCall Action security.Action ResultCh chan PermissionResolutionDecision Submitted bool diff --git a/internal/runtime/permission_test.go b/internal/runtime/permission_test.go index 23102aea..c82a9526 100644 --- a/internal/runtime/permission_test.go +++ b/internal/runtime/permission_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" "neo-code/internal/security" "neo-code/internal/tools" ) @@ -53,7 +53,7 @@ func TestResolvePermissionSuccess(t *testing.T) { request := registerPendingPermission(service, permissionExecutionInput{ RunID: "run-permission", SessionID: "session-permission", - Call: provider.ToolCall{ + Call: providertypes.ToolCall{ ID: "call-1", Name: "webfetch", }, @@ -105,7 +105,7 @@ func TestResolvePermissionDuplicateSubmissionIsNonBlocking(t *testing.T) { request := registerPendingPermission(service, permissionExecutionInput{ RunID: "run-permission-dup", SessionID: "session-permission-dup", - Call: provider.ToolCall{ + Call: providertypes.ToolCall{ ID: "call-dup", Name: "webfetch", }, @@ -170,13 +170,19 @@ func TestServiceRunPermissionRejectFlow(t *testing.T) { } scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ + responses: []scriptedResponse{ { - provider.NewToolCallStartStreamEvent(0, "call-ask-reject", "webfetch"), - provider.NewToolCallDeltaStreamEvent(0, "call-ask-reject", `{"url":"https://example.com/private"}`), + Message: providertypes.Message{ + Role: "assistant", + ToolCalls: []providertypes.ToolCall{ + {ID: "call-ask-reject", Name: "webfetch", Arguments: `{"url":"https://example.com/private"}`}, + }, + }, + FinishReason: "tool_calls", }, { - provider.NewTextDeltaStreamEvent("done"), + Message: providertypes.Message{Role: "assistant", Content: "done"}, + FinishReason: "stop", }, }, } @@ -306,7 +312,7 @@ func TestResolvePermissionCanceledContext(t *testing.T) { request := registerPendingPermission(service, permissionExecutionInput{ RunID: "run-canceled", SessionID: "session-canceled", - Call: provider.ToolCall{ + Call: providertypes.ToolCall{ ID: "call-canceled", Name: "webfetch", }, diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index b9e97b1d..7b727e08 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -17,6 +17,7 @@ import ( agentcontext "neo-code/internal/context" contextcompact "neo-code/internal/context/compact" "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" "neo-code/internal/tools" ) @@ -34,13 +35,13 @@ const ( // 包括文本内容和工具调用列表。 type streamAccumulator struct { content strings.Builder - toolCalls map[int]*provider.ToolCall + toolCalls map[int]*providertypes.ToolCall } // newStreamAccumulator 创建并初始化一个空的流式事件累积器。 func newStreamAccumulator() *streamAccumulator { return &streamAccumulator{ - toolCalls: make(map[int]*provider.ToolCall), + toolCalls: make(map[int]*providertypes.ToolCall), } } @@ -50,10 +51,10 @@ func (a *streamAccumulator) accumulateTextDelta(text string) { } // ensureToolCall 返回指定索引的工具调用条目,不存在时会先创建占位对象。 -func (a *streamAccumulator) ensureToolCall(index int) *provider.ToolCall { +func (a *streamAccumulator) ensureToolCall(index int) *providertypes.ToolCall { call, exists := a.toolCalls[index] if !exists { - call = &provider.ToolCall{} + call = &providertypes.ToolCall{} a.toolCalls[index] = call } return call @@ -80,15 +81,15 @@ func (a *streamAccumulator) accumulateToolCallDelta(index int, id, argumentsDelt } // buildMessage 从累积状态构建最终的 assistant Message 对象,并校验工具调用元数据是否完整。 -func (a *streamAccumulator) buildMessage() (provider.Message, error) { +func (a *streamAccumulator) buildMessage() (providertypes.Message, error) { ordered := make([]int, 0, len(a.toolCalls)) for index := range a.toolCalls { ordered = append(ordered, index) } sort.Ints(ordered) - message := provider.Message{ - Role: provider.RoleAssistant, + message := providertypes.Message{ + Role: providertypes.RoleAssistant, Content: a.content.String(), } for _, index := range ordered { @@ -97,10 +98,10 @@ func (a *streamAccumulator) buildMessage() (provider.Message, error) { continue } if strings.TrimSpace(call.ID) == "" { - return provider.Message{}, fmt.Errorf("runtime: provider emitted tool call %d without id", index) + return providertypes.Message{}, fmt.Errorf("runtime: provider emitted tool call %d without id", index) } if strings.TrimSpace(call.Name) == "" { - return provider.Message{}, fmt.Errorf("runtime: provider emitted tool call %d without name", index) + return providertypes.Message{}, fmt.Errorf("runtime: provider emitted tool call %d without name", index) } message.ToolCalls = append(message.ToolCalls, *call) } @@ -200,8 +201,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { return s.handleRunError(ctx, input.RunID, input.SessionID, err) } - userMessage := provider.Message{ - Role: provider.RoleUser, + userMessage := providertypes.Message{ + Role: providertypes.RoleUser, Content: input.Content, } session.Messages = append(session.Messages, userMessage) @@ -251,7 +252,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { return s.handleRunError(ctx, input.RunID, session.ID, err) } - acc, err := s.callProviderWithRetry(ctx, input.RunID, session.ID, provider.ChatRequest{ + acc, err := s.callProviderWithRetry(ctx, input.RunID, session.ID, providertypes.ChatRequest{ Model: cfg.CurrentModel, SystemPrompt: builtContext.SystemPrompt, Messages: builtContext.Messages, @@ -273,7 +274,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { return s.handleRunError(ctx, input.RunID, session.ID, err) } if strings.TrimSpace(assistant.Role) == "" { - assistant.Role = provider.RoleAssistant + assistant.Role = providertypes.RoleAssistant } if strings.TrimSpace(assistant.Content) != "" || len(assistant.ToolCalls) > 0 { @@ -326,8 +327,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { s.emit(ctx, EventPermissionResolved, input.RunID, session.ID, permissionEvent.toResolvedPayload()) } - toolMessage := provider.Message{ - Role: provider.RoleTool, + toolMessage := providertypes.Message{ + Role: providertypes.RoleTool, Content: result.Content, ToolCallID: call.ID, IsError: result.IsError, @@ -496,13 +497,13 @@ func (s *Service) emit(ctx context.Context, kind EventType, runID string, sessio // handleProviderStreamEvent 解析并应用单条 provider 流式事件,缺失载荷或未知类型时返回错误。 func handleProviderStreamEvent( - event provider.StreamEvent, + event providertypes.StreamEvent, acc *streamAccumulator, onTextDelta func(string), - onToolCallStart func(provider.ToolCallStartPayload), + onToolCallStart func(providertypes.ToolCallStartPayload), ) error { switch event.Type { - case provider.StreamEventTextDelta: + case providertypes.StreamEventTextDelta: payload, err := event.TextDeltaValue() if err != nil { return err @@ -513,7 +514,7 @@ func handleProviderStreamEvent( if acc != nil { acc.accumulateTextDelta(payload.Text) } - case provider.StreamEventToolCallStart: + case providertypes.StreamEventToolCallStart: payload, err := event.ToolCallStartValue() if err != nil { return err @@ -524,7 +525,7 @@ func handleProviderStreamEvent( if acc != nil { acc.accumulateToolCallStart(payload.Index, payload.ID, payload.Name) } - case provider.StreamEventToolCallDelta: + case providertypes.StreamEventToolCallDelta: payload, err := event.ToolCallDeltaValue() if err != nil { return err @@ -532,7 +533,7 @@ func handleProviderStreamEvent( if acc != nil { acc.accumulateToolCallDelta(payload.Index, payload.ID, payload.ArgumentsDelta) } - case provider.StreamEventMessageDone: + case providertypes.StreamEventMessageDone: if _, err := event.MessageDoneValue(); err != nil { return err } @@ -548,7 +549,7 @@ func (s *Service) forwardProviderEvents( ctx context.Context, runID string, sessionID string, - input <-chan provider.StreamEvent, + input <-chan providertypes.StreamEvent, done chan<- error, acc *streamAccumulator, ) { @@ -569,7 +570,7 @@ func (s *Service) forwardProviderEvents( func(text string) { s.emit(ctx, EventAgentChunk, runID, sessionID, text) }, - func(payload provider.ToolCallStartPayload) { + func(payload providertypes.ToolCallStartPayload) { s.emit(ctx, EventToolCallThinking, runID, sessionID, payload.Name) }, ) @@ -640,7 +641,7 @@ func (s *Service) callProviderWithRetry( ctx context.Context, runID string, sessionID string, - req provider.ChatRequest, + req providertypes.ChatRequest, ) (*streamAccumulator, error) { acc := newStreamAccumulator() var lastErr error @@ -671,7 +672,7 @@ func (s *Service) callProviderWithRetry( return nil, err } - streamEvents := make(chan provider.StreamEvent, 32) + streamEvents := make(chan providertypes.StreamEvent, 32) streamDone := make(chan error, 1) go s.forwardProviderEvents(ctx, runID, sessionID, streamEvents, streamDone, acc) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 64f32bb5..0fe9f86a 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -14,6 +14,7 @@ import ( agentcontext "neo-code/internal/context" contextcompact "neo-code/internal/context/compact" "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" "neo-code/internal/security" "neo-code/internal/tools" ) @@ -90,19 +91,19 @@ func (s *memoryStore) ListSummaries(ctx context.Context) ([]SessionSummary, erro type scriptedProvider struct { name string - streams [][]provider.StreamEvent + streams [][]providertypes.StreamEvent responses []scriptedResponse - requests []provider.ChatRequest + requests []providertypes.ChatRequest callCount int - chatFn func(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error + chatFn func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error } type scriptedResponse struct { - Message provider.Message + Message providertypes.Message FinishReason string } -func (p *scriptedProvider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error { +func (p *scriptedProvider) Chat(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { p.requests = append(p.requests, cloneChatRequest(req)) callIndex := p.callCount @@ -125,25 +126,25 @@ func (p *scriptedProvider) Chat(ctx context.Context, req provider.ChatRequest, e response := p.responses[callIndex] for index, toolCall := range response.Message.ToolCalls { select { - case events <- provider.NewToolCallStartStreamEvent(index, toolCall.ID, toolCall.Name): + case events <- providertypes.NewToolCallStartStreamEvent(index, toolCall.ID, toolCall.Name): case <-ctx.Done(): return ctx.Err() } select { - case events <- provider.NewToolCallDeltaStreamEvent(index, toolCall.ID, toolCall.Arguments): + case events <- providertypes.NewToolCallDeltaStreamEvent(index, toolCall.ID, toolCall.Arguments): case <-ctx.Done(): return ctx.Err() } } if response.Message.Content != "" { select { - case events <- provider.NewTextDeltaStreamEvent(response.Message.Content): + case events <- providertypes.NewTextDeltaStreamEvent(response.Message.Content): case <-ctx.Done(): return ctx.Err() } } select { - case events <- provider.NewMessageDoneStreamEvent(response.FinishReason, nil): + case events <- providertypes.NewMessageDoneStreamEvent(response.FinishReason, nil): case <-ctx.Done(): return ctx.Err() } @@ -227,12 +228,12 @@ func (b *stubContextBuilder) Build(ctx context.Context, input agentcontext.Build } return agentcontext.BuildResult{ SystemPrompt: "stub system prompt", - Messages: append([]provider.Message(nil), input.Messages...), + Messages: append([]providertypes.Message(nil), input.Messages...), }, nil } type stubToolManager struct { - specs []provider.ToolSpec + specs []providertypes.ToolSpec result tools.ToolResult err error listErr error @@ -248,7 +249,7 @@ type stubToolManager struct { } } -func (m *stubToolManager) ListAvailableSpecs(ctx context.Context, input tools.SpecListInput) ([]provider.ToolSpec, error) { +func (m *stubToolManager) ListAvailableSpecs(ctx context.Context, input tools.SpecListInput) ([]providertypes.ToolSpec, error) { m.listCalls++ if err := ctx.Err(); err != nil { return nil, err @@ -256,7 +257,7 @@ func (m *stubToolManager) ListAvailableSpecs(ctx context.Context, input tools.Sp if m.listErr != nil { return nil, m.listErr } - return append([]provider.ToolSpec(nil), m.specs...), nil + return append([]providertypes.ToolSpec(nil), m.specs...), nil } func (m *stubToolManager) MicroCompactPolicy(name string) tools.MicroCompactPolicy { @@ -293,7 +294,7 @@ func TestServiceRun(t *testing.T) { tests := []struct { name string input UserInput - providerStreams [][]provider.StreamEvent + providerStreams [][]providertypes.StreamEvent registerTool tools.Tool contextBuilder agentcontext.Builder expectProviderCalls int @@ -305,17 +306,17 @@ func TestServiceRun(t *testing.T) { { name: "normal dialogue exits after final assistant reply", input: UserInput{RunID: "run-normal", Content: "hello"}, - providerStreams: [][]provider.StreamEvent{ + providerStreams: [][]providertypes.StreamEvent{ { - provider.NewTextDeltaStreamEvent("plain "), - provider.NewTextDeltaStreamEvent("answer"), + providertypes.NewTextDeltaStreamEvent("plain "), + providertypes.NewTextDeltaStreamEvent("answer"), }, }, contextBuilder: &stubContextBuilder{ buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { return agentcontext.BuildResult{ SystemPrompt: "custom system prompt", - Messages: []provider.Message{ + Messages: []providertypes.Message{ {Role: "user", Content: "trimmed history"}, }, }, nil @@ -346,13 +347,13 @@ func TestServiceRun(t *testing.T) { input: UserInput{RunID: "run-tool", Content: "edit file"}, // 第一轮:工具调用事件流(tool_call_start + tool_call_delta) // 第二轮:普通文本回复 - providerStreams: [][]provider.StreamEvent{ + providerStreams: [][]providertypes.StreamEvent{ { - provider.NewToolCallStartStreamEvent(0, "call-1", "filesystem_edit"), - provider.NewToolCallDeltaStreamEvent(0, "call-1", `{"path":"main.go"}`), + providertypes.NewToolCallStartStreamEvent(0, "call-1", "filesystem_edit"), + providertypes.NewToolCallDeltaStreamEvent(0, "call-1", `{"path":"main.go"}`), }, { - provider.NewTextDeltaStreamEvent("done"), + providertypes.NewTextDeltaStreamEvent("done"), }, }, registerTool: &stubTool{ @@ -458,13 +459,13 @@ func TestServiceRunMergesLateToolCallMetadata(t *testing.T) { registry.Register(tool) scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ + streams: [][]providertypes.StreamEvent{ { - provider.NewToolCallDeltaStreamEvent(0, "", `{"path":"main.go"`), - provider.NewToolCallStartStreamEvent(0, "call-late", "filesystem_edit"), - provider.NewToolCallDeltaStreamEvent(0, "call-late", `}`), + providertypes.NewToolCallDeltaStreamEvent(0, "", `{"path":"main.go"`), + providertypes.NewToolCallStartStreamEvent(0, "call-late", "filesystem_edit"), + providertypes.NewToolCallDeltaStreamEvent(0, "call-late", `}`), }, - {provider.NewTextDeltaStreamEvent("done")}, + {providertypes.NewTextDeltaStreamEvent("done")}, }, } @@ -511,10 +512,10 @@ func TestServiceRunRejectsToolCallWithoutID(t *testing.T) { registry.Register(tool) scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ + streams: [][]providertypes.StreamEvent{ { - provider.NewToolCallStartStreamEvent(0, "", "filesystem_edit"), - provider.NewToolCallDeltaStreamEvent(0, "", `{}`), + providertypes.NewToolCallStartStreamEvent(0, "", "filesystem_edit"), + providertypes.NewToolCallDeltaStreamEvent(0, "", `{}`), }, }, } @@ -538,9 +539,9 @@ func TestServiceRunRejectsMalformedProviderStreamEvent(t *testing.T) { registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ + streams: [][]providertypes.StreamEvent{ { - {Type: provider.StreamEventTextDelta}, + {Type: providertypes.StreamEventTextDelta}, }, }, } @@ -560,12 +561,12 @@ func TestServiceRunMalformedProviderStreamEventDoesNotDeadlock(t *testing.T) { registry := tools.NewRegistry() registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) - stream := []provider.StreamEvent{{Type: provider.StreamEventTextDelta}} + stream := []providertypes.StreamEvent{{Type: providertypes.StreamEventTextDelta}} for i := 0; i < 40; i++ { - stream = append(stream, provider.NewTextDeltaStreamEvent("ignored")) + stream = append(stream, providertypes.NewTextDeltaStreamEvent("ignored")) } scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{stream}, + streams: [][]providertypes.StreamEvent{stream}, } service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) @@ -593,7 +594,7 @@ type stubCompactRunner struct { func (r *stubCompactRunner) Run(ctx context.Context, input contextcompact.Input) (contextcompact.Result, error) { cloned := input - cloned.Messages = append([]provider.Message(nil), input.Messages...) + cloned.Messages = append([]providertypes.Message(nil), input.Messages...) r.calls = append(r.calls, cloned) if r.runFn != nil { return r.runFn(ctx, input) @@ -616,7 +617,7 @@ func TestServiceRunDelegatesToContextBuilder(t *testing.T) { buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { return agentcontext.BuildResult{ SystemPrompt: "delegated prompt", - Messages: []provider.Message{ + Messages: []providertypes.Message{ {Role: "user", Content: "delegated message"}, }, }, nil @@ -624,8 +625,8 @@ func TestServiceRunDelegatesToContextBuilder(t *testing.T) { } scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ - {provider.NewTextDeltaStreamEvent("done")}, + streams: [][]providertypes.StreamEvent{ + {providertypes.NewTextDeltaStreamEvent("done")}, }, } @@ -686,14 +687,14 @@ func TestServiceRunCanDisableMicroCompactViaConfig(t *testing.T) { buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { return agentcontext.BuildResult{ SystemPrompt: "delegated prompt", - Messages: append([]provider.Message(nil), input.Messages...), + Messages: append([]providertypes.Message(nil), input.Messages...), }, nil }, } scripted := &scriptedProvider{ responses: []scriptedResponse{{ - Message: provider.Message{Role: provider.RoleAssistant, Content: "done"}, + Message: providertypes.Message{Role: providertypes.RoleAssistant, Content: "done"}, FinishReason: "stop", }}, } @@ -717,8 +718,8 @@ func TestServiceRunPersistsSessionProviderAndModel(t *testing.T) { registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ - {provider.NewTextDeltaStreamEvent("done")}, + streams: [][]providertypes.StreamEvent{ + {providertypes.NewTextDeltaStreamEvent("done")}, }, } @@ -749,35 +750,35 @@ func TestServiceRunDefaultBuilderUsesToolManagerMicroCompactPolicies(t *testing. session := newSession("preserve history") session.ID = "session-preserve-history" - session.Messages = []provider.Message{ - {Role: provider.RoleUser, Content: "older user"}, + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "preserve_tool", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-1", Content: "preserved result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "preserved result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, } store.sessions[session.ID] = cloneSession(session) scripted := &scriptedProvider{ responses: []scriptedResponse{{ - Message: provider.Message{Role: provider.RoleAssistant, Content: "done"}, + Message: providertypes.Message{Role: providertypes.RoleAssistant, Content: "done"}, FinishReason: "stop", }}, } @@ -812,35 +813,35 @@ func TestServiceRunDefaultBuilderUsesGenericToolManagerMicroCompactPolicies(t *t session := newSession("preserve history by manager") session.ID = "session-preserve-history-manager" - session.Messages = []provider.Message{ - {Role: provider.RoleUser, Content: "older user"}, + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older user"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-1", Name: "preserve_tool", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-1", Content: "preserved result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "preserved result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-2", Name: "bash", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "recent bash result"}, { - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ {ID: "call-3", Name: "webfetch", Arguments: "{}"}, }, }, - {Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"}, } store.sessions[session.ID] = cloneSession(session) scripted := &scriptedProvider{ responses: []scriptedResponse{{ - Message: provider.Message{Role: provider.RoleAssistant, Content: "done"}, + Message: providertypes.Message{Role: providertypes.RoleAssistant, Content: "done"}, FinishReason: "stop", }}, } @@ -883,8 +884,8 @@ func TestServiceRunFailurePreservesExistingSessionProviderAndModel(t *testing.T) session.ID = "session-preserve-metadata" session.Provider = config.OpenAIName session.Model = "openai-original-model" - session.Messages = []provider.Message{ - {Role: provider.RoleUser, Content: "earlier"}, + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "earlier"}, } store.sessions[session.ID] = cloneSession(session) @@ -924,7 +925,7 @@ func TestServiceRunUsesToolManager(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() toolManager := &stubToolManager{ - specs: []provider.ToolSpec{ + specs: []providertypes.ToolSpec{ {Name: "filesystem_edit", Description: "stub", Schema: map[string]any{"type": "object"}}, }, result: tools.ToolResult{ @@ -934,12 +935,12 @@ func TestServiceRunUsesToolManager(t *testing.T) { } scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ + streams: [][]providertypes.StreamEvent{ { - provider.NewToolCallStartStreamEvent(0, "call-manager", "filesystem_edit"), - provider.NewToolCallDeltaStreamEvent(0, "call-manager", `{"path":"main.go"}`), + providertypes.NewToolCallStartStreamEvent(0, "call-manager", "filesystem_edit"), + providertypes.NewToolCallDeltaStreamEvent(0, "call-manager", `{"path":"main.go"}`), }, - {provider.NewTextDeltaStreamEvent("done")}, + {providertypes.NewTextDeltaStreamEvent("done")}, }, } @@ -964,7 +965,7 @@ func TestServiceRunUsesToolManager(t *testing.T) { session := onlySession(t, store) foundToolMessage := false for _, message := range session.Messages { - if message.Role == provider.RoleTool && message.Content == "tool manager output" { + if message.Role == providertypes.RoleTool && message.Content == "tool manager output" { foundToolMessage = true break } @@ -1004,12 +1005,12 @@ func TestServiceRunWaitsForPermissionResolutionAndContinues(t *testing.T) { } scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ + streams: [][]providertypes.StreamEvent{ { - provider.NewToolCallStartStreamEvent(0, "call-ask", "webfetch"), - provider.NewToolCallDeltaStreamEvent(0, "call-ask", `{"url":"https://example.com/private"}`), + providertypes.NewToolCallStartStreamEvent(0, "call-ask", "webfetch"), + providertypes.NewToolCallDeltaStreamEvent(0, "call-ask", `{"url":"https://example.com/private"}`), }, - {provider.NewTextDeltaStreamEvent("done")}, + {providertypes.NewTextDeltaStreamEvent("done")}, }, } @@ -1120,12 +1121,12 @@ func TestServiceRunEmitsPermissionResolvedForDeny(t *testing.T) { } scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ + streams: [][]providertypes.StreamEvent{ { - provider.NewToolCallStartStreamEvent(0, "call-deny", "bash"), - provider.NewToolCallDeltaStreamEvent(0, "call-deny", `{"command":"echo hi"}`), + providertypes.NewToolCallStartStreamEvent(0, "call-deny", "bash"), + providertypes.NewToolCallDeltaStreamEvent(0, "call-deny", `{"command":"echo hi"}`), }, - {provider.NewTextDeltaStreamEvent("done")}, + {providertypes.NewTextDeltaStreamEvent("done")}, }, } @@ -1207,13 +1208,19 @@ func TestServiceRunEmitsRememberScopeWhenSessionRejectMemoryHits(t *testing.T) { } scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ + responses: []scriptedResponse{ { - provider.NewToolCallStartStreamEvent(0, "call-memory-reject", "webfetch"), - provider.NewToolCallDeltaStreamEvent(0, "call-memory-reject", `{"url":"https://example.com/private"}`), + Message: providertypes.Message{ + Role: "assistant", + ToolCalls: []providertypes.ToolCall{ + {ID: "call-memory-reject", Name: "webfetch", Arguments: `{"url":"https://example.com/private"}`}, + }, + }, + FinishReason: "tool_calls", }, { - provider.NewTextDeltaStreamEvent("done"), + Message: providertypes.Message{Role: "assistant", Content: "done"}, + FinishReason: "stop", }, }, } @@ -1272,7 +1279,7 @@ func TestServiceRunHandlesToolManagerSpecError(t *testing.T) { assertEventsRunID(t, events, input.RunID) session := onlySession(t, store) - if len(session.Messages) != 1 || session.Messages[0].Role != provider.RoleUser { + if len(session.Messages) != 1 || session.Messages[0].Role != providertypes.RoleUser { t.Fatalf("expected only user message to persist, got %+v", session.Messages) } } @@ -1282,8 +1289,8 @@ func TestServiceNewWithFactoryDefaultsToolManager(t *testing.T) { store := newMemoryStore() service := NewWithFactory(manager, nil, store, &scriptedProviderFactory{ provider: &scriptedProvider{ - streams: [][]provider.StreamEvent{ - {provider.NewTextDeltaStreamEvent("done")}, + streams: [][]providertypes.StreamEvent{ + {providertypes.NewTextDeltaStreamEvent("done")}, }, }, }, nil) @@ -1325,10 +1332,10 @@ func TestServiceRunErrorPaths(t *testing.T) { input: UserInput{RunID: "run-max-loops", Content: "loop"}, maxLoops: 1, provider: &scriptedProvider{ - streams: [][]provider.StreamEvent{ + streams: [][]providertypes.StreamEvent{ { - provider.NewToolCallStartStreamEvent(0, "loop-call", "filesystem_edit"), - provider.NewToolCallDeltaStreamEvent(0, "loop-call", `{"path":"x"}`), + providertypes.NewToolCallStartStreamEvent(0, "loop-call", "filesystem_edit"), + providertypes.NewToolCallDeltaStreamEvent(0, "loop-call", `{"path":"x"}`), }, }, }, @@ -1364,8 +1371,8 @@ func TestServiceRunErrorPaths(t *testing.T) { Content: "continue", }, provider: &scriptedProvider{ - streams: [][]provider.StreamEvent{ - {provider.NewTextDeltaStreamEvent("resumed")}, + streams: [][]providertypes.StreamEvent{ + {providertypes.NewTextDeltaStreamEvent("resumed")}, }, }, seedSession: &Session{ @@ -1373,7 +1380,7 @@ func TestServiceRunErrorPaths(t *testing.T) { Title: "Resume Me", CreatedAt: newSession("seed").CreatedAt, UpdatedAt: newSession("seed").UpdatedAt, - Messages: []provider.Message{ + Messages: []providertypes.Message{ {Role: "user", Content: "earlier"}, }, }, @@ -1396,7 +1403,7 @@ func TestServiceRunErrorPaths(t *testing.T) { callIdx := 0 return &scriptedProvider{ name: "retry-then-success", - chatFn: func(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { callIdx++ if callIdx == 1 { return &provider.ProviderError{ @@ -1406,7 +1413,7 @@ func TestServiceRunErrorPaths(t *testing.T) { Retryable: true, } } - events <- provider.NewTextDeltaStreamEvent("recovered") + events <- providertypes.NewTextDeltaStreamEvent("recovered") return nil }, } @@ -1431,7 +1438,7 @@ func TestServiceRunErrorPaths(t *testing.T) { input: UserInput{RunID: "run-no-retry", Content: "hello"}, provider: &scriptedProvider{ name: "auth-error-no-retry", - chatFn: func(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { return &provider.ProviderError{ StatusCode: 401, Code: provider.ErrorCodeAuthFailed, @@ -1454,7 +1461,7 @@ func TestServiceRunErrorPaths(t *testing.T) { input: UserInput{RunID: "run-retry-exhausted", Content: "hello"}, provider: &scriptedProvider{ name: "always-500", - chatFn: func(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { return &provider.ProviderError{ StatusCode: 500, Code: provider.ErrorCodeServer, @@ -1538,7 +1545,7 @@ func TestServiceCancelActiveRun(t *testing.T) { started := make(chan struct{}) scripted := &scriptedProvider{ name: "cancel-active-run-provider", - chatFn: func(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { close(started) <-ctx.Done() return ctx.Err() @@ -1581,7 +1588,7 @@ func TestServiceRunCanceledByProvider(t *testing.T) { started := make(chan struct{}) scripted := &scriptedProvider{ name: "blocking-provider", - chatFn: func(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { close(started) <-ctx.Done() return ctx.Err() @@ -1626,7 +1633,7 @@ func TestServiceRunPreservesProviderErrorAfterCancel(t *testing.T) { providerErr := errors.New("provider failed after cancel") scripted := &scriptedProvider{ name: "provider-error-after-cancel", - chatFn: func(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { close(started) <-ctx.Done() return providerErr @@ -1678,10 +1685,10 @@ func TestServiceRunCanceledDuringToolExecution(t *testing.T) { scripted := &scriptedProvider{ name: "tool-cancel-provider", - streams: [][]provider.StreamEvent{ + streams: [][]providertypes.StreamEvent{ { - provider.NewToolCallStartStreamEvent(0, "cancel-call", "filesystem_edit"), - provider.NewToolCallDeltaStreamEvent(0, "cancel-call", `{"path":"main.go"}`), + providertypes.NewToolCallStartStreamEvent(0, "cancel-call", "filesystem_edit"), + providertypes.NewToolCallDeltaStreamEvent(0, "cancel-call", `{"path":"main.go"}`), }, }, } @@ -1738,10 +1745,10 @@ func TestServiceRunPreservesToolErrorAfterCancel(t *testing.T) { scripted := &scriptedProvider{ name: "tool-error-after-cancel-provider", - streams: [][]provider.StreamEvent{ + streams: [][]providertypes.StreamEvent{ { - provider.NewToolCallStartStreamEvent(0, "tool-error-call", "filesystem_edit"), - provider.NewToolCallDeltaStreamEvent(0, "tool-error-call", `{"path":"main.go"}`), + providertypes.NewToolCallStartStreamEvent(0, "tool-error-call", "filesystem_edit"), + providertypes.NewToolCallDeltaStreamEvent(0, "tool-error-call", `{"path":"main.go"}`), }, }, } @@ -1830,12 +1837,12 @@ func TestServiceRunToolTimeoutIsNotCancellation(t *testing.T) { scripted := &scriptedProvider{ name: "timeout-provider", - streams: [][]provider.StreamEvent{ + streams: [][]providertypes.StreamEvent{ { - provider.NewToolCallStartStreamEvent(0, "timeout-call", "filesystem_edit"), - provider.NewToolCallDeltaStreamEvent(0, "timeout-call", `{"path":"main.go"}`), + providertypes.NewToolCallStartStreamEvent(0, "timeout-call", "filesystem_edit"), + providertypes.NewToolCallDeltaStreamEvent(0, "timeout-call", `{"path":"main.go"}`), }, - {provider.NewTextDeltaStreamEvent("done after timeout")}, + {providertypes.NewTextDeltaStreamEvent("done after timeout")}, }, } @@ -1864,10 +1871,10 @@ func TestServiceCompactManualAppliesAndPersists(t *testing.T) { store := newMemoryStore() session := newSession("manual") session.ID = "session-manual" - session.Messages = []provider.Message{ - {Role: provider.RoleUser, Content: "older"}, - {Role: provider.RoleAssistant, Content: "older answer"}, - {Role: provider.RoleUser, Content: "before"}, + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + {Role: providertypes.RoleUser, Content: "before"}, } store.sessions[session.ID] = cloneSession(session) @@ -1877,9 +1884,9 @@ func TestServiceCompactManualAppliesAndPersists(t *testing.T) { service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) service.compactRunner = &stubCompactRunner{ result: contextcompact.Result{ - Messages: []provider.Message{ - {Role: provider.RoleAssistant, Content: "[compact_summary]\ndone:\n- ok\n\nin_progress:\n- continue"}, - {Role: provider.RoleAssistant, Content: "latest"}, + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- ok\n\nin_progress:\n- continue"}, + {Role: providertypes.RoleAssistant, Content: "latest"}, }, Applied: true, Metrics: contextcompact.Metrics{ @@ -1922,10 +1929,10 @@ func TestServiceCompactManualFailureReturnsError(t *testing.T) { store := newMemoryStore() session := newSession("manual-fail") session.ID = "session-manual-fail" - session.Messages = []provider.Message{ - {Role: provider.RoleUser, Content: "older"}, - {Role: provider.RoleAssistant, Content: "older answer"}, - {Role: provider.RoleUser, Content: "before"}, + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + {Role: providertypes.RoleUser, Content: "before"}, } store.sessions[session.ID] = cloneSession(session) @@ -1978,10 +1985,10 @@ func TestServiceCompactUsesSessionProviderAndModelWhenPresent(t *testing.T) { session.ID = "session-manual-provider" session.Provider = config.OpenAIName session.Model = "session-model" - session.Messages = []provider.Message{ - {Role: provider.RoleUser, Content: "older"}, - {Role: provider.RoleAssistant, Content: "older answer"}, - {Role: provider.RoleUser, Content: "before"}, + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + {Role: providertypes.RoleUser, Content: "before"}, } store.sessions[session.ID] = cloneSession(session) @@ -1989,8 +1996,8 @@ func TestServiceCompactUsesSessionProviderAndModelWhenPresent(t *testing.T) { registry.Register(&stubTool{name: "filesystem_read_file", content: "ok"}) scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ - {provider.NewTextDeltaStreamEvent(strings.Join([]string{ + streams: [][]providertypes.StreamEvent{ + {providertypes.NewTextDeltaStreamEvent(strings.Join([]string{ "[compact_summary]", "done:", "- ok", @@ -2050,10 +2057,10 @@ func TestServiceCompactFallsBackToCurrentProviderWhenSessionMetadataMissing(t *t store := newMemoryStore() session := newSession("manual-fallback") session.ID = "session-manual-fallback" - session.Messages = []provider.Message{ - {Role: provider.RoleUser, Content: "older"}, - {Role: provider.RoleAssistant, Content: "older answer"}, - {Role: provider.RoleUser, Content: "before"}, + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + {Role: providertypes.RoleUser, Content: "before"}, } store.sessions[session.ID] = cloneSession(session) @@ -2061,8 +2068,8 @@ func TestServiceCompactFallsBackToCurrentProviderWhenSessionMetadataMissing(t *t registry.Register(&stubTool{name: "filesystem_read_file", content: "ok"}) scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ - {provider.NewTextDeltaStreamEvent(strings.Join([]string{ + streams: [][]providertypes.StreamEvent{ + {providertypes.NewTextDeltaStreamEvent(strings.Join([]string{ "[compact_summary]", "done:", "- ok", @@ -2107,9 +2114,9 @@ func TestServiceManualCompactThenRunContinuesToolRound(t *testing.T) { store := newMemoryStore() session := newSession("manual-continue") session.ID = "session-manual-continue" - session.Messages = []provider.Message{ - {Role: provider.RoleUser, Content: "legacy request"}, - {Role: provider.RoleAssistant, Content: "legacy answer"}, + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "legacy request"}, + {Role: providertypes.RoleAssistant, Content: "legacy answer"}, } store.sessions[session.ID] = cloneSession(session) @@ -2118,12 +2125,12 @@ func TestServiceManualCompactThenRunContinuesToolRound(t *testing.T) { registry.Register(tool) scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ + streams: [][]providertypes.StreamEvent{ { - provider.NewToolCallStartStreamEvent(0, "call-1", "filesystem_read_file"), - provider.NewToolCallDeltaStreamEvent(0, "call-1", `{"path":"main.go"}`), + providertypes.NewToolCallStartStreamEvent(0, "call-1", "filesystem_read_file"), + providertypes.NewToolCallDeltaStreamEvent(0, "call-1", `{"path":"main.go"}`), }, - {provider.NewTextDeltaStreamEvent("done")}, + {providertypes.NewTextDeltaStreamEvent("done")}, }, } @@ -2131,9 +2138,9 @@ func TestServiceManualCompactThenRunContinuesToolRound(t *testing.T) { service.compactRunner = &stubCompactRunner{ runFn: func(ctx context.Context, input contextcompact.Input) (contextcompact.Result, error) { return contextcompact.Result{ - Messages: []provider.Message{ - {Role: provider.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, - {Role: provider.RoleAssistant, Content: "latest answer"}, + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, + {Role: providertypes.RoleAssistant, Content: "latest answer"}, }, Applied: true, Metrics: contextcompact.Metrics{ @@ -2198,14 +2205,14 @@ func TestServiceSerializesRunAndCompact(t *testing.T) { providerStarted := make(chan struct{}) unblockProvider := make(chan struct{}) scripted := &scriptedProvider{ - chatFn: func(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error { + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { select { case <-providerStarted: default: close(providerStarted) } <-unblockProvider - events <- provider.NewTextDeltaStreamEvent("done") + events <- providertypes.NewTextDeltaStreamEvent("done") return nil }, } @@ -2216,7 +2223,7 @@ func TestServiceSerializesRunAndCompact(t *testing.T) { runFn: func(ctx context.Context, input contextcompact.Input) (contextcompact.Result, error) { compactEntered <- struct{}{} return contextcompact.Result{ - Messages: append([]provider.Message(nil), input.Messages...), + Messages: append([]providertypes.Message(nil), input.Messages...), Metrics: contextcompact.Metrics{ BeforeChars: 1, AfterChars: 1, @@ -2330,12 +2337,12 @@ func TestServiceRunUsesSessionWorkdirForContextAndTools(t *testing.T) { builder := &stubContextBuilder{} scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ + streams: [][]providertypes.StreamEvent{ { - provider.NewToolCallStartStreamEvent(0, "call-session-workdir", "filesystem_edit"), - provider.NewToolCallDeltaStreamEvent(0, "call-session-workdir", `{"path":"main.go"}`), + providertypes.NewToolCallStartStreamEvent(0, "call-session-workdir", "filesystem_edit"), + providertypes.NewToolCallDeltaStreamEvent(0, "call-session-workdir", `{"path":"main.go"}`), }, - {provider.NewTextDeltaStreamEvent("done")}, + {providertypes.NewTextDeltaStreamEvent("done")}, }, } @@ -2372,8 +2379,8 @@ func TestServiceRunUsesInputWorkdirForNewSession(t *testing.T) { registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) builder := &stubContextBuilder{} scripted := &scriptedProvider{ - streams: [][]provider.StreamEvent{ - {provider.NewTextDeltaStreamEvent("done")}, + streams: [][]providertypes.StreamEvent{ + {providertypes.NewTextDeltaStreamEvent("done")}, }, } @@ -2608,20 +2615,20 @@ func assertEventsRunID(t *testing.T, events []RuntimeEvent, runID string) { func cloneSession(session Session) Session { cloned := session - cloned.Messages = append([]provider.Message(nil), session.Messages...) + cloned.Messages = append([]providertypes.Message(nil), session.Messages...) return cloned } -func cloneChatRequest(req provider.ChatRequest) provider.ChatRequest { +func cloneChatRequest(req providertypes.ChatRequest) providertypes.ChatRequest { cloned := req - cloned.Messages = append([]provider.Message(nil), req.Messages...) - cloned.Tools = append([]provider.ToolSpec(nil), req.Tools...) + cloned.Messages = append([]providertypes.Message(nil), req.Messages...) + cloned.Tools = append([]providertypes.ToolSpec(nil), req.Tools...) return cloned } func cloneBuildInput(input agentcontext.BuildInput) agentcontext.BuildInput { cloned := input - cloned.Messages = append([]provider.Message(nil), input.Messages...) + cloned.Messages = append([]providertypes.Message(nil), input.Messages...) return cloned } diff --git a/internal/runtime/session.go b/internal/runtime/session.go index af64317d..24378d28 100644 --- a/internal/runtime/session.go +++ b/internal/runtime/session.go @@ -12,7 +12,7 @@ import ( "sync" "time" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) const sessionsDirName = "sessions" @@ -23,11 +23,11 @@ type Session struct { // Provider 记录最近一次成功运行会话时使用的 provider,用于 compact 优先复用历史配置。 Provider string `json:"provider,omitempty"` // Model 记录最近一次成功运行会话时使用的 model,用于 compact 优先复用历史配置。 - Model string `json:"model,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Workdir string `json:"-"` - Messages []provider.Message `json:"messages"` + Model string `json:"model,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Workdir string `json:"-"` + Messages []providertypes.Message `json:"messages"` } type SessionSummary struct { @@ -181,7 +181,7 @@ func newSessionWithWorkdir(title string, workdir string) Session { CreatedAt: now, UpdatedAt: now, Workdir: strings.TrimSpace(workdir), - Messages: []provider.Message{}, + Messages: []providertypes.Message{}, } } diff --git a/internal/runtime/session_test.go b/internal/runtime/session_test.go index a82f1215..06faa257 100644 --- a/internal/runtime/session_test.go +++ b/internal/runtime/session_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) func TestJSONSessionStoreSaveLoadAndListSummaries(t *testing.T) { @@ -22,7 +22,7 @@ func TestJSONSessionStoreSaveLoadAndListSummaries(t *testing.T) { Title: "Old Session", CreatedAt: time.Now().Add(-2 * time.Hour), UpdatedAt: time.Now().Add(-1 * time.Hour), - Messages: []provider.Message{ + Messages: []providertypes.Message{ {Role: "user", Content: "hello"}, {Role: "assistant", Content: "world"}, }, @@ -33,7 +33,7 @@ func TestJSONSessionStoreSaveLoadAndListSummaries(t *testing.T) { CreatedAt: time.Now().Add(-30 * time.Minute), UpdatedAt: time.Now(), Workdir: t.TempDir(), - Messages: []provider.Message{ + Messages: []providertypes.Message{ {Role: "user", Content: "new"}, }, } @@ -119,7 +119,7 @@ func TestJSONSessionStoreCorruptedSessionBehaviors(t *testing.T) { Title: "Valid Session", CreatedAt: time.Now().Add(-time.Minute), UpdatedAt: time.Now(), - Messages: []provider.Message{{Role: "user", Content: "hello"}}, + Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, } if err := store.Save(context.Background(), valid); err != nil { t.Fatalf("Save valid session: %v", err) diff --git a/internal/tools/manager.go b/internal/tools/manager.go index b91af823..dab53833 100644 --- a/internal/tools/manager.go +++ b/internal/tools/manager.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" "neo-code/internal/security" ) @@ -18,7 +18,7 @@ type SpecListInput struct { // Manager is the runtime-facing tool execution and schema exposure boundary. type Manager interface { - ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]provider.ToolSpec, error) + ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) MicroCompactPolicy(name string) MicroCompactPolicy Execute(ctx context.Context, input ToolCallInput) (ToolResult, error) RememberSessionDecision(sessionID string, action security.Action, scope SessionPermissionScope) error @@ -26,7 +26,7 @@ type Manager interface { // Executor is the concrete tool execution layer under the manager. type Executor interface { - ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]provider.ToolSpec, error) + ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) Execute(ctx context.Context, input ToolCallInput) (ToolResult, error) Supports(name string) bool } @@ -161,7 +161,7 @@ func NewManager(executor Executor, engine security.PermissionEngine, sandbox Wor } // ListAvailableSpecs returns the currently visible tool specs from the executor. -func (m *DefaultManager) ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]provider.ToolSpec, error) { +func (m *DefaultManager) ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) { if m == nil || m.executor == nil { return nil, errors.New("tools: manager executor is nil") } diff --git a/internal/tools/manager_test.go b/internal/tools/manager_test.go index 96d1b75a..45e7de66 100644 --- a/internal/tools/manager_test.go +++ b/internal/tools/manager_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" "neo-code/internal/security" ) @@ -47,7 +47,7 @@ type stubSandbox struct { type executorWithoutMicroCompactPolicy struct{} -func (executorWithoutMicroCompactPolicy) ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]provider.ToolSpec, error) { +func (executorWithoutMicroCompactPolicy) ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) { if err := ctx.Err(); err != nil { return nil, err } diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 4ebc8234..90f8a485 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -6,7 +6,7 @@ import ( "sort" "strings" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" "neo-code/internal/security" ) @@ -65,17 +65,17 @@ func (r *Registry) MicroCompactPolicy(name string) MicroCompactPolicy { return MicroCompactPolicyCompact } -func (r *Registry) GetSpecs() []provider.ToolSpec { +func (r *Registry) GetSpecs() []providertypes.ToolSpec { names := make([]string, 0, len(r.tools)) for name := range r.tools { names = append(names, name) } sort.Strings(names) - specs := make([]provider.ToolSpec, 0, len(names)) + specs := make([]providertypes.ToolSpec, 0, len(names)) for _, name := range names { tool := r.tools[name] - specs = append(specs, provider.ToolSpec{ + specs = append(specs, providertypes.ToolSpec{ Name: tool.Name(), Description: tool.Description(), Schema: tool.Schema(), @@ -84,12 +84,12 @@ func (r *Registry) GetSpecs() []provider.ToolSpec { return specs } -func (r *Registry) ListSchemas() []provider.ToolSpec { +func (r *Registry) ListSchemas() []providertypes.ToolSpec { return r.GetSpecs() } // ListAvailableSpecs returns all registered tool specs. -func (r *Registry) ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]provider.ToolSpec, error) { +func (r *Registry) ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) { if err := ctx.Err(); err != nil { return nil, err } diff --git a/internal/tools/types.go b/internal/tools/types.go index 4a0a0a32..77bd75bb 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -3,7 +3,7 @@ package tools import ( "context" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" "neo-code/internal/security" ) @@ -35,4 +35,4 @@ type ToolResult struct { Metadata map[string]any } -type ToolSpec = provider.ToolSpec +type ToolSpec = providertypes.ToolSpec diff --git a/internal/tui/app.go b/internal/tui/app.go index eae67f5d..19816f03 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -15,7 +15,7 @@ import ( "github.com/charmbracelet/lipgloss" "neo-code/internal/config" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" ) @@ -47,7 +47,7 @@ type App struct { inputBurstCount int pasteMode bool pendingPermission *pendingPermissionPrompt - activeMessages []provider.Message + activeMessages []providertypes.Message activities []activityEntry fileCandidates []string modelRefreshID string diff --git a/internal/tui/copy_code_test.go b/internal/tui/copy_code_test.go index ae9e4305..9cadcee0 100644 --- a/internal/tui/copy_code_test.go +++ b/internal/tui/copy_code_test.go @@ -7,7 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) func TestExtractFencedCodeBlocks(t *testing.T) { @@ -136,7 +136,7 @@ func TestTranscriptMouseClickCopiesCodeBlock(t *testing.T) { app.width = 128 app.height = 40 - app.activeMessages = []provider.Message{ + app.activeMessages = []providertypes.Message{ {Role: roleAssistant, Content: "```go\nfmt.Println(1)\n```"}, } app.applyComponentLayout(true) @@ -223,7 +223,7 @@ func TestTranscriptMouseCopyFailureSetsError(t *testing.T) { app.width = 128 app.height = 40 - app.activeMessages = []provider.Message{ + app.activeMessages = []providertypes.Message{ {Role: roleAssistant, Content: "```txt\nhello\n```"}, } app.applyComponentLayout(true) @@ -266,6 +266,6 @@ func TestTranscriptMouseCopyFailureSetsError(t *testing.T) { } } -func providerMessage(role, content string) provider.Message { - return provider.Message{Role: role, Content: content} +func providerMessage(role, content string) providertypes.Message { + return providertypes.Message{Role: role, Content: content} } diff --git a/internal/tui/update.go b/internal/tui/update.go index 4836fd28..8a2a513c 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -16,7 +16,7 @@ import ( "github.com/charmbracelet/lipgloss" "neo-code/internal/config" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" "neo-code/internal/tools" ) @@ -426,7 +426,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.state.ExecutionError = "" a.state.StatusText = statusThinking a.state.CurrentTool = "" - a.activeMessages = append(a.activeMessages, provider.Message{Role: roleUser, Content: input}) + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleUser, Content: input}) a.rebuildTranscript() requestedWorkdir := "" if strings.TrimSpace(a.state.ActiveSessionID) == "" { @@ -711,7 +711,7 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { case agentruntime.EventToolStart: a.state.StatusText = statusRunningTool a.state.StreamingReply = false - if payload, ok := event.Payload.(provider.ToolCall); ok { + if payload, ok := event.Payload.(providertypes.ToolCall); ok { a.state.CurrentTool = payload.Name a.setRunProgress(0.6, "Running tool") a.appendActivity("tool", "Running tool", payload.Name, false) @@ -721,7 +721,7 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { a.state.CurrentTool = "" a.setRunProgress(0.8, "Integrating result") if payload, ok := event.Payload.(tools.ToolResult); ok { - a.activeMessages = append(a.activeMessages, provider.Message{ + a.activeMessages = append(a.activeMessages, providertypes.Message{ Role: roleTool, Content: payload.Content, IsError: payload.IsError, @@ -758,8 +758,8 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { if strings.TrimSpace(a.state.ExecutionError) == "" { a.state.StatusText = statusReady } - if payload, ok := event.Payload.(provider.Message); ok && strings.TrimSpace(payload.Content) != "" && !a.lastAssistantMatches(payload.Content) { - a.activeMessages = append(a.activeMessages, provider.Message{Role: roleAssistant, Content: payload.Content}) + if payload, ok := event.Payload.(providertypes.Message); ok && strings.TrimSpace(payload.Content) != "" && !a.lastAssistantMatches(payload.Content) { + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleAssistant, Content: payload.Content}) transcriptDirty = true } case agentruntime.EventRunCanceled: @@ -871,7 +871,7 @@ func (a *App) appendAssistantChunk(chunk string) { } if !a.state.StreamingReply || len(a.activeMessages) == 0 || a.activeMessages[len(a.activeMessages)-1].Role != roleAssistant { - a.activeMessages = append(a.activeMessages, provider.Message{Role: roleAssistant, Content: chunk}) + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleAssistant, Content: chunk}) a.state.StreamingReply = true return } @@ -885,7 +885,7 @@ func (a *App) appendInlineMessage(role string, message string) { return } - a.activeMessages = append(a.activeMessages, provider.Message{Role: role, Content: content}) + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: role, Content: content}) } func (a *App) appendActivity(kind string, title string, detail string, isError bool) { diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 6a741a06..a02958f7 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -20,6 +20,7 @@ import ( contextcompact "neo-code/internal/context/compact" "neo-code/internal/provider" providercatalog "neo-code/internal/provider/catalog" + providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" "neo-code/internal/tools" ) @@ -675,7 +676,7 @@ func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { msg: RuntimeMsg{Event: agentruntime.RuntimeEvent{ Type: agentruntime.EventAgentDone, SessionID: "session-2", - Payload: provider.Message{ + Payload: providertypes.Message{ Role: roleAssistant, Content: "final", }, @@ -778,7 +779,7 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { now := agentruntime.Session{ ID: "session-1", Title: "Existing Session", - Messages: []provider.Message{ + Messages: []providertypes.Message{ {Role: roleUser, Content: "hi"}, {Role: roleAssistant, Content: "hello"}, }, @@ -932,12 +933,12 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if app.statusBadge("error: boom") == "" || app.statusBadge("running now") == "" { t.Fatalf("expected status badge variants") } - if rendered, _ := app.renderMessageBlockWithCopy(provider.Message{Role: roleError, Content: "boom"}, 80, 1); rendered == "" { + if rendered, _ := app.renderMessageBlockWithCopy(providertypes.Message{Role: roleError, Content: "boom"}, 80, 1); rendered == "" { t.Fatalf("expected error message block") } - if rendered, _ := app.renderMessageBlockWithCopy(provider.Message{ + if rendered, _ := app.renderMessageBlockWithCopy(providertypes.Message{ Role: roleAssistant, - ToolCalls: []provider.ToolCall{ + ToolCalls: []providertypes.ToolCall{ {Name: "filesystem_edit"}, }, }, 80, 1); rendered == "" { @@ -1194,7 +1195,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.state.ActiveSessionID = "existing" app.state.ActiveSessionTitle = "Existing" - app.activeMessages = []provider.Message{{Role: roleUser, Content: "hello"}} + app.activeMessages = []providertypes.Message{{Role: roleUser, Content: "hello"}} }, msg: tea.KeyMsg{Type: tea.KeyCtrlN}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { @@ -1211,7 +1212,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { runtime.loads["s1"] = agentruntime.Session{ ID: "s1", Title: "One", - Messages: []provider.Message{{Role: roleAssistant, Content: "loaded"}}, + Messages: []providertypes.Message{{Role: roleAssistant, Content: "loaded"}}, } if err := app.refreshSessions(); err != nil { t.Fatalf("refresh sessions: %v", err) @@ -1786,7 +1787,7 @@ func TestAppHandleRuntimeEventAdditionalBranches(t *testing.T) { event: agentruntime.RuntimeEvent{ Type: agentruntime.EventToolStart, SessionID: "s1", - Payload: provider.ToolCall{ + Payload: providertypes.ToolCall{ Name: "filesystem_edit", }, }, @@ -2446,7 +2447,7 @@ func TestRenderMessageBlockUserContentAlignsWithUserTag(t *testing.T) { t.Fatalf("New() error = %v", err) } - renderedMessage, _ := app.renderMessageBlockWithCopy(provider.Message{Role: roleUser, Content: "nihao"}, 80, 1) + renderedMessage, _ := app.renderMessageBlockWithCopy(providertypes.Message{Role: roleUser, Content: "nihao"}, 80, 1) rendered := stripANSI(renderedMessage) lines := strings.Split(rendered, "\n") @@ -2735,7 +2736,7 @@ func newTestProviderService(t *testing.T, manager *config.Manager) *config.Selec type tUItestProvider struct{} -func (tUItestProvider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error { +func (tUItestProvider) Chat(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { return nil } diff --git a/internal/tui/view.go b/internal/tui/view.go index f248f0a7..8398f4ef 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -7,7 +7,7 @@ import ( "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/lipgloss" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) type layout struct { @@ -229,7 +229,7 @@ func (a App) renderPanel(title string, subtitle string, body string, width int, return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, panel) } -func (a App) renderMessageBlockWithCopy(message provider.Message, width int, startCopyID int) (string, []copyCodeButtonBinding) { +func (a App) renderMessageBlockWithCopy(message providertypes.Message, width int, startCopyID int) (string, []copyCodeButtonBinding) { switch message.Role { case roleEvent: return a.styles.inlineNotice.Width(width).Render(" > " + wrapPlain(message.Content, max(16, width-6))), nil From 04ccc2e84c90c7d8191fa046df81b232a3d461f0 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 7 Apr 2026 17:24:57 +0800 Subject: [PATCH 2/8] =?UTF-8?q?refator:=E6=8B=86=E5=88=86=E5=87=BAsession?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- docs/session-persistence-design.md | 16 ++++- internal/app/bootstrap.go | 3 +- internal/runtime/compact.go | 9 +-- internal/runtime/id.go | 12 ---- internal/runtime/runtime.go | 37 +++++----- internal/runtime/runtime_test.go | 71 ++++++++++--------- internal/runtime/workdir_branch_test.go | 4 +- internal/session/id.go | 18 +++++ .../{runtime/session.go => session/store.go} | 68 +++++++++++------- .../session_test.go => session/store_test.go} | 24 +++---- internal/tui/state.go | 6 +- internal/tui/state/ui_state.go | 4 +- internal/tui/update_test.go | 41 +++++------ 14 files changed, 178 insertions(+), 139 deletions(-) delete mode 100644 internal/runtime/id.go create mode 100644 internal/session/id.go rename internal/{runtime/session.go => session/store.go} (58%) rename internal/{runtime/session_test.go => session/store_test.go} (87%) diff --git a/README.md b/README.md index d710be7c..33fdaa73 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,8 @@ go run ./cmd/neocode - **`internal/provider/catalog`** — 模型发现、catalog 缓存与后台刷新 - **`internal/provider/selection`** — provider/model 选择与配置同步 - **`internal/provider/builtin`** — 内建 driver 注册 -- **`internal/runtime`** — ReAct 主循环、事件流、会话管理 +- **`internal/runtime`** — ReAct 主循环与事件流编排(不直接承载会话存储实现;不再导出会话模型与存储类型) +- **`internal/session`** — 会话模型、会话存储抽象与 JSON 持久化实现(统一对外暴露 `Session` / `Summary` / `Store`) - **`internal/tools`** — 工具注册表与具体工具实现 - **`internal/tui`** — 终端 UI、交互体验、事件桥接 - **`internal/app`** — 应用装配与依赖注入 @@ -116,6 +117,7 @@ go run ./cmd/neocode │ │ ├── catalog # 模型发现与缓存 │ │ └── selection # provider/model 选择服务 │ ├── runtime # ReAct 循环与事件流 +│ ├── session # 会话模型与持久化 │ ├── tools # 工具系统 │ └── tui # 终端 UI └── README.md diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md index 0c5d1887..e66f7800 100644 --- a/docs/session-persistence-design.md +++ b/docs/session-persistence-design.md @@ -1,10 +1,16 @@ # Session 持久化设计 + +## 模块职责与收口边界 +- `internal/session`:承载会话领域模型、存储抽象与 JSON 持久化实现,是唯一的会话持久化实现归属层 +- `internal/runtime`:只依赖 `internal/session` 提供的抽象与模型,负责会话保存时机与主循环编排,不再维护会话存储实现细节 +- `internal/tui`:仅消费 runtime 暴露的会话数据,不直接执行会话持久化 + ## 存储策略 NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优先、易于调试和跨平台可移植。 ## 数据模型 - `Session`:完整消息历史以及 `id`、`title`、`updated_at` 等元信息 -- `SessionSummary`:用于侧边栏的轻量摘要结构 +- `Summary`:用于侧边栏的轻量摘要结构(原 `SessionSummary` 命名已统一收口为 `Summary`) ## 加载策略 - `ListSummaries` 只读取渲染侧边栏所需的基础信息 @@ -12,9 +18,13 @@ NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优 - `Save` 通过临时文件原子写入完整 Session ## 命名策略 -- 新会话默认展示为 `Draft` +- 新会话默认展示为 `New Session` - 一旦持久化,runtime 会根据首轮用户消息生成简短标题 ## 并发约束 -- SessionStore 实现必须自行保护共享访问 +- `internal/session` 中的 Store 实现必须自行保护共享访问 - 真正的保存时机由 runtime 决定,TUI 不负责直接触发磁盘写入 + +## 兼容性与演进说明 +- 会话持久化能力已从 runtime 侧实现中彻底收口到 `internal/session` +- 新增会话存储实现时,应优先在 `internal/session` 内扩展并通过接口注入 runtime,避免跨层实现 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index dd0b8436..6d2892b3 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -12,6 +12,7 @@ import ( providercatalog "neo-code/internal/provider/catalog" agentruntime "neo-code/internal/runtime" "neo-code/internal/security" + agentsession "neo-code/internal/session" "neo-code/internal/tools" "neo-code/internal/tools/bash" "neo-code/internal/tools/filesystem" @@ -62,7 +63,7 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { return nil, err } - sessionStore := agentruntime.NewSessionStore(loader.BaseDir()) + sessionStore := agentsession.NewStore(loader.BaseDir()) runtimeSvc := agentruntime.NewWithFactory( manager, toolManager, diff --git a/internal/runtime/compact.go b/internal/runtime/compact.go index 4939ca62..3b1040a1 100644 --- a/internal/runtime/compact.go +++ b/internal/runtime/compact.go @@ -9,6 +9,7 @@ import ( "neo-code/internal/config" contextcompact "neo-code/internal/context/compact" "neo-code/internal/provider" + agentsession "neo-code/internal/session" ) // CompactInput 描述一次手动 compact 请求所需的最小输入。 @@ -83,10 +84,10 @@ func (s *Service) Compact(ctx context.Context, input CompactInput) (CompactResul func (s *Service) runCompactForSession( ctx context.Context, runID string, - session Session, + session agentsession.Session, cfg config.Config, failOnError bool, -) (Session, contextcompact.Result, error) { +) (agentsession.Session, contextcompact.Result, error) { runner := s.compactRunner if runner == nil { var err error @@ -155,7 +156,7 @@ func (s *Service) runCompactForSession( } // defaultCompactRunner 为手动 compact 选择摘要生成器并构造默认 runner。 -func (s *Service) defaultCompactRunner(session Session, cfg config.Config) (contextcompact.Runner, error) { +func (s *Service) defaultCompactRunner(session agentsession.Session, cfg config.Config) (contextcompact.Runner, error) { resolvedProvider, model, err := resolveCompactProviderSelection(session, cfg) if err != nil { return nil, err @@ -164,7 +165,7 @@ func (s *Service) defaultCompactRunner(session Session, cfg config.Config) (cont } // resolveCompactProviderSelection 优先复用会话记录的 provider/model,缺失时再回退当前配置。 -func resolveCompactProviderSelection(session Session, cfg config.Config) (config.ResolvedProviderConfig, string, error) { +func resolveCompactProviderSelection(session agentsession.Session, cfg config.Config) (config.ResolvedProviderConfig, string, error) { sessionProvider := strings.TrimSpace(session.Provider) sessionModel := strings.TrimSpace(session.Model) if sessionProvider != "" && sessionModel != "" { diff --git a/internal/runtime/id.go b/internal/runtime/id.go deleted file mode 100644 index b036fbea..00000000 --- a/internal/runtime/id.go +++ /dev/null @@ -1,12 +0,0 @@ -package runtime - -import ( - "crypto/rand" - "encoding/hex" -) - -func newID(prefix string) string { - buf := make([]byte, 8) - _, _ = rand.Read(buf) - return prefix + "_" + hex.EncodeToString(buf) -} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index b9e97b1d..28a21f42 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -17,6 +17,7 @@ import ( agentcontext "neo-code/internal/context" contextcompact "neo-code/internal/context/compact" "neo-code/internal/provider" + agentsession "neo-code/internal/session" "neo-code/internal/tools" ) @@ -120,9 +121,9 @@ type Runtime interface { ResolvePermission(ctx context.Context, input PermissionResolutionInput) error CancelActiveRun() bool Events() <-chan RuntimeEvent - ListSessions(ctx context.Context) ([]SessionSummary, error) - LoadSession(ctx context.Context, id string) (Session, error) - SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (Session, error) + ListSessions(ctx context.Context) ([]agentsession.Summary, error) + LoadSession(ctx context.Context, id string) (agentsession.Session, error) + SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) } type UserInput struct { @@ -138,7 +139,7 @@ type ProviderFactory interface { type Service struct { configManager *config.Manager // 配置管理器,提供当前选中的 provider、model、workdir 等配置读取能力。 - sessionStore Store // 会话持久化接口,负责保存和加载聊天会话。 + sessionStore agentsession.Store // 会话持久化接口,负责保存和加载聊天会话。 toolManager tools.Manager // 工具管理器,统一工具 schema 暴露与执行入口。 providerFactory ProviderFactory // Provider 工厂接口,根据配置动态创建具体的 provider 实例。 contextBuilder agentcontext.Builder // 上下文构建器,负责组装 system prompt 与本轮发给模型的消息上下文。 @@ -154,7 +155,7 @@ type Service struct { func NewWithFactory( configManager *config.Manager, toolManager tools.Manager, - sessionStore Store, + sessionStore agentsession.Store, providerFactory ProviderFactory, contextBuilder agentcontext.Builder, ) *Service { @@ -372,35 +373,35 @@ func (s *Service) Events() <-chan RuntimeEvent { return s.events } -func (s *Service) ListSessions(ctx context.Context) ([]SessionSummary, error) { +func (s *Service) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { return s.sessionStore.ListSummaries(ctx) } -func (s *Service) LoadSession(ctx context.Context, id string) (Session, error) { +func (s *Service) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { session, err := s.sessionStore.Load(ctx, id) if err != nil { - return Session{}, err + return agentsession.Session{}, err } session.Workdir = s.sessionWorkdir(id, session.Workdir) return session, nil } -func (s *Service) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (Session, error) { +func (s *Service) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { sessionID = strings.TrimSpace(sessionID) if sessionID == "" { - return Session{}, errors.New("runtime: session id is empty") + return agentsession.Session{}, errors.New("runtime: session id is empty") } session, err := s.sessionStore.Load(ctx, sessionID) if err != nil { - return Session{}, err + return agentsession.Session{}, err } session.Workdir = s.sessionWorkdir(sessionID, session.Workdir) cfg := s.configManager.Get() resolved, err := resolveWorkdirForSession(cfg.Workdir, session.Workdir, workdir) if err != nil { - return Session{}, err + return agentsession.Session{}, err } if session.Workdir == resolved { return session, nil @@ -439,22 +440,22 @@ func (s *Service) loadOrCreateSession( title string, defaultWorkdir string, requestedWorkdir string, -) (Session, error) { +) (agentsession.Session, error) { if strings.TrimSpace(sessionID) == "" { sessionWorkdir, err := resolveWorkdirForSession(defaultWorkdir, "", requestedWorkdir) if err != nil { - return Session{}, err + return agentsession.Session{}, err } - session := newSessionWithWorkdir(title, sessionWorkdir) + session := agentsession.NewWithWorkdir(title, sessionWorkdir) s.setSessionWorkdir(session.ID, sessionWorkdir) if err := s.sessionStore.Save(ctx, &session); err != nil { - return Session{}, err + return agentsession.Session{}, err } return session, nil } session, err := s.sessionStore.Load(ctx, sessionID) if err != nil { - return Session{}, err + return agentsession.Session{}, err } session.Workdir = s.sessionWorkdir(sessionID, session.Workdir) if strings.TrimSpace(requestedWorkdir) == "" && strings.TrimSpace(session.Workdir) != "" { @@ -463,7 +464,7 @@ func (s *Service) loadOrCreateSession( resolved, err := resolveWorkdirForSession(defaultWorkdir, session.Workdir, requestedWorkdir) if err != nil { - return Session{}, err + return agentsession.Session{}, err } if session.Workdir == resolved { return session, nil diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 64f32bb5..d010a908 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -15,16 +15,17 @@ import ( contextcompact "neo-code/internal/context/compact" "neo-code/internal/provider" "neo-code/internal/security" + agentsession "neo-code/internal/session" "neo-code/internal/tools" ) type memoryStore struct { - sessions map[string]Session + sessions map[string]agentsession.Session saves int } type failingStore struct { - Store + agentsession.Store saveErr error failOnSave int saveCalls int @@ -32,10 +33,10 @@ type failingStore struct { } func newMemoryStore() *memoryStore { - return &memoryStore{sessions: map[string]Session{}} + return &memoryStore{sessions: map[string]agentsession.Session{}} } -func (s *failingStore) Save(ctx context.Context, session *Session) error { +func (s *failingStore) Save(ctx context.Context, session *agentsession.Session) error { s.saveCalls++ if s.failOnSave > 0 && s.saveCalls == s.failOnSave { return s.saveErr @@ -49,7 +50,7 @@ func (s *failingStore) Save(ctx context.Context, session *Session) error { return s.Store.Save(ctx, session) } -func (s *memoryStore) Save(ctx context.Context, session *Session) error { +func (s *memoryStore) Save(ctx context.Context, session *agentsession.Session) error { if err := ctx.Err(); err != nil { return err } @@ -61,24 +62,24 @@ func (s *memoryStore) Save(ctx context.Context, session *Session) error { return nil } -func (s *memoryStore) Load(ctx context.Context, id string) (Session, error) { +func (s *memoryStore) Load(ctx context.Context, id string) (agentsession.Session, error) { if err := ctx.Err(); err != nil { - return Session{}, err + return agentsession.Session{}, err } session, ok := s.sessions[id] if !ok { - return Session{}, errors.New("not found") + return agentsession.Session{}, errors.New("not found") } return cloneSession(session), nil } -func (s *memoryStore) ListSummaries(ctx context.Context) ([]SessionSummary, error) { +func (s *memoryStore) ListSummaries(ctx context.Context) ([]agentsession.Summary, error) { if err := ctx.Err(); err != nil { return nil, err } - summaries := make([]SessionSummary, 0, len(s.sessions)) + summaries := make([]agentsession.Summary, 0, len(s.sessions)) for _, session := range s.sessions { - summaries = append(summaries, SessionSummary{ + summaries = append(summaries, agentsession.Summary{ ID: session.ID, Title: session.Title, CreatedAt: session.CreatedAt, @@ -606,7 +607,7 @@ func TestServiceRunDelegatesToContextBuilder(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("memory reject") + session := agentsession.New("memory reject") session.ID = "session-memory-reject" store.sessions[session.ID] = cloneSession(session) registry := tools.NewRegistry() @@ -747,7 +748,7 @@ func TestServiceRunDefaultBuilderUsesToolManagerMicroCompactPolicies(t *testing. registry.Register(&stubTool{name: "bash", content: "default"}) registry.Register(&stubTool{name: "webfetch", content: "default"}) - session := newSession("preserve history") + session := agentsession.New("preserve history") session.ID = "session-preserve-history" session.Messages = []provider.Message{ {Role: provider.RoleUser, Content: "older user"}, @@ -810,7 +811,7 @@ func TestServiceRunDefaultBuilderUsesGenericToolManagerMicroCompactPolicies(t *t }, } - session := newSession("preserve history by manager") + session := agentsession.New("preserve history by manager") session.ID = "session-preserve-history-manager" session.Messages = []provider.Message{ {Role: provider.RoleUser, Content: "older user"}, @@ -879,7 +880,7 @@ func TestServiceRunFailurePreservesExistingSessionProviderAndModel(t *testing.T) } store := newMemoryStore() - session := newSession("preserve-metadata") + session := agentsession.New("preserve-metadata") session.ID = "session-preserve-metadata" session.Provider = config.OpenAIName session.Model = "openai-original-model" @@ -979,7 +980,7 @@ func TestServiceRunWaitsForPermissionResolutionAndContinues(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("memory reject") + session := agentsession.New("memory reject") session.ID = "session-memory-reject" store.sessions[session.ID] = cloneSession(session) registry := tools.NewRegistry() @@ -1170,7 +1171,7 @@ func TestServiceRunEmitsRememberScopeWhenSessionRejectMemoryHits(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("memory reject") + session := agentsession.New("memory reject") session.ID = "session-memory-reject" store.sessions[session.ID] = cloneSession(session) registry := tools.NewRegistry() @@ -1304,7 +1305,7 @@ func TestServiceRunErrorPaths(t *testing.T) { provider *scriptedProvider factoryErr error registerTool *stubTool - seedSession *Session + seedSession *agentsession.Session expectErr string expectEvents []EventType assert func(t *testing.T, store *memoryStore, provider *scriptedProvider, tool *stubTool) @@ -1368,11 +1369,11 @@ func TestServiceRunErrorPaths(t *testing.T) { {provider.NewTextDeltaStreamEvent("resumed")}, }, }, - seedSession: &Session{ + seedSession: &agentsession.Session{ ID: "existing-session", Title: "Resume Me", - CreatedAt: newSession("seed").CreatedAt, - UpdatedAt: newSession("seed").UpdatedAt, + CreatedAt: agentsession.New("seed").CreatedAt, + UpdatedAt: agentsession.New("seed").UpdatedAt, Messages: []provider.Message{ {Role: "user", Content: "earlier"}, }, @@ -1862,7 +1863,7 @@ func TestServiceRunToolTimeoutIsNotCancellation(t *testing.T) { func TestServiceCompactManualAppliesAndPersists(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("manual") + session := agentsession.New("manual") session.ID = "session-manual" session.Messages = []provider.Message{ {Role: provider.RoleUser, Content: "older"}, @@ -1920,7 +1921,7 @@ func TestServiceCompactManualAppliesAndPersists(t *testing.T) { func TestServiceCompactManualFailureReturnsError(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("manual-fail") + session := agentsession.New("manual-fail") session.ID = "session-manual-fail" session.Messages = []provider.Message{ {Role: provider.RoleUser, Content: "older"}, @@ -1974,7 +1975,7 @@ func TestServiceCompactUsesSessionProviderAndModelWhenPresent(t *testing.T) { } store := newMemoryStore() - session := newSession("manual-provider") + session := agentsession.New("manual-provider") session.ID = "session-manual-provider" session.Provider = config.OpenAIName session.Model = "session-model" @@ -2048,7 +2049,7 @@ func TestServiceCompactFallsBackToCurrentProviderWhenSessionMetadataMissing(t *t } store := newMemoryStore() - session := newSession("manual-fallback") + session := agentsession.New("manual-fallback") session.ID = "session-manual-fallback" session.Messages = []provider.Message{ {Role: provider.RoleUser, Content: "older"}, @@ -2105,7 +2106,7 @@ func TestServiceCompactFallsBackToCurrentProviderWhenSessionMetadataMissing(t *t func TestServiceManualCompactThenRunContinuesToolRound(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("manual-continue") + session := agentsession.New("manual-continue") session.ID = "session-manual-continue" session.Messages = []provider.Message{ {Role: provider.RoleUser, Content: "legacy request"}, @@ -2188,7 +2189,7 @@ func TestServiceManualCompactThenRunContinuesToolRound(t *testing.T) { func TestServiceSerializesRunAndCompact(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("serialized") + session := agentsession.New("serialized") session.ID = "session-serialized" store.sessions[session.ID] = cloneSession(session) @@ -2284,7 +2285,7 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { t.Fatalf("expected events channel") } - session := newSession("List Me") + session := agentsession.New("List Me") store.sessions[session.ID] = cloneSession(session) summaries, err := service.ListSessions(context.Background()) @@ -2303,7 +2304,7 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { t.Fatalf("expected loaded session %q, got %q", session.ID, loaded.ID) } - sessionStore := NewSessionStore(t.TempDir()) + sessionStore := agentsession.NewStore(t.TempDir()) if sessionStore == nil { t.Fatalf("expected JSON session store") } @@ -2321,7 +2322,7 @@ func TestServiceRunUsesSessionWorkdirForContextAndTools(t *testing.T) { } store := newMemoryStore() - session := newSessionWithWorkdir("Session Workdir", sessionWorkdir) + session := agentsession.NewWithWorkdir("Session Workdir", sessionWorkdir) store.sessions[session.ID] = cloneSession(session) tool := &stubTool{name: "filesystem_edit", content: "ok"} @@ -2410,7 +2411,7 @@ func TestServiceSetSessionWorkdir(t *testing.T) { } store := newMemoryStore() - session := newSession("set workdir") + session := agentsession.New("set workdir") store.sessions[session.ID] = cloneSession(session) registry := tools.NewRegistry() registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) @@ -2541,7 +2542,7 @@ func restoreRuntimeEnv(t *testing.T, key string) { }) } -func onlySession(t *testing.T, store *memoryStore) Session { +func onlySession(t *testing.T, store *memoryStore) agentsession.Session { t.Helper() if len(store.sessions) != 1 { t.Fatalf("expected exactly 1 session, got %d", len(store.sessions)) @@ -2549,7 +2550,7 @@ func onlySession(t *testing.T, store *memoryStore) Session { for _, session := range store.sessions { return session } - return Session{} + return agentsession.Session{} } func resolvedProviderForTests(cfg config.Config, providerName string) (config.ResolvedProviderConfig, error) { @@ -2606,7 +2607,7 @@ func assertEventsRunID(t *testing.T, events []RuntimeEvent, runID string) { } } -func cloneSession(session Session) Session { +func cloneSession(session agentsession.Session) agentsession.Session { cloned := session cloned.Messages = append([]provider.Message(nil), session.Messages...) return cloned @@ -2691,7 +2692,7 @@ func TestServiceSetSessionWorkdirNoopDoesNotSave(t *testing.T) { store := newMemoryStore() target := t.TempDir() - session := newSessionWithWorkdir("noop", target) + session := agentsession.NewWithWorkdir("noop", target) store.sessions[session.ID] = cloneSession(session) registry := tools.NewRegistry() registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) diff --git a/internal/runtime/workdir_branch_test.go b/internal/runtime/workdir_branch_test.go index 0edc39ef..266fed5e 100644 --- a/internal/runtime/workdir_branch_test.go +++ b/internal/runtime/workdir_branch_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + agentsession "neo-code/internal/session" ) func TestSessionWorkdirKeyAndMemoryMap(t *testing.T) { @@ -82,7 +84,7 @@ func TestLoadSessionUsesFallbackWorkdirWhenMemoryMissing(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("fallback") + session := agentsession.New("fallback") session.Workdir = t.TempDir() store.sessions[session.ID] = cloneSession(session) diff --git a/internal/session/id.go b/internal/session/id.go new file mode 100644 index 00000000..dc2e84e9 --- /dev/null +++ b/internal/session/id.go @@ -0,0 +1,18 @@ +package session + +import ( + "crypto/rand" + "encoding/hex" +) + +// NewID 生成带前缀的随机 ID,格式为 "_<16hex>"。 +func NewID(prefix string) string { + buf := make([]byte, 8) + _, _ = rand.Read(buf) + return prefix + "_" + hex.EncodeToString(buf) +} + +// newID 保留为内部兼容入口,后续代码请优先使用 NewID。 +func newID(prefix string) string { + return NewID(prefix) +} diff --git a/internal/runtime/session.go b/internal/session/store.go similarity index 58% rename from internal/runtime/session.go rename to internal/session/store.go index af64317d..4deeffec 100644 --- a/internal/runtime/session.go +++ b/internal/session/store.go @@ -1,4 +1,4 @@ -package runtime +package session import ( "context" @@ -17,6 +17,8 @@ import ( const sessionsDirName = "sessions" +// Session 表示单个会话的持久化模型,包含基础元数据与消息历史。 +// Provider / Model 用于在 compact 等流程中优先复用会话最近一次成功运行的模型配置。 type Session struct { ID string `json:"id"` Title string `json:"title"` @@ -30,71 +32,78 @@ type Session struct { Messages []provider.Message `json:"messages"` } -type SessionSummary struct { +// Summary 表示会话列表视图所需的轻量摘要信息。 +type Summary struct { ID string `json:"id"` Title string `json:"title"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +// Store 定义会话持久化抽象。 type Store interface { Save(ctx context.Context, session *Session) error Load(ctx context.Context, id string) (Session, error) - ListSummaries(ctx context.Context) ([]SessionSummary, error) + ListSummaries(ctx context.Context) ([]Summary, error) } -type JSONSessionStore struct { +// JSONStore 是基于 JSON 文件的会话存储实现。 +type JSONStore struct { mu sync.RWMutex baseDir string } -func NewJSONSessionStore(baseDir string) *JSONSessionStore { - return &JSONSessionStore{ +// NewJSONStore 创建 JSONStore,实际会话目录为 {baseDir}/sessions。 +func NewJSONStore(baseDir string) *JSONStore { + return &JSONStore{ baseDir: filepath.Join(baseDir, sessionsDirName), } } -func NewSessionStore(baseDir string) *JSONSessionStore { - return NewJSONSessionStore(baseDir) +// NewStore 返回默认会话存储实现(当前为 JSONStore)。 +func NewStore(baseDir string) *JSONStore { + return NewJSONStore(baseDir) } -func (s *JSONSessionStore) Save(ctx context.Context, session *Session) error { +// Save 持久化会话到 JSON 文件,采用临时文件 + 原子替换策略。 +func (s *JSONStore) Save(ctx context.Context, session *Session) error { if err := ctx.Err(); err != nil { return err } if session == nil { - return errors.New("runtime: session is nil") + return errors.New("session: session is nil") } s.mu.Lock() defer s.mu.Unlock() if err := os.MkdirAll(s.baseDir, 0o755); err != nil { - return fmt.Errorf("runtime: create sessions dir: %w", err) + return fmt.Errorf("session: create sessions dir: %w", err) } payload, err := json.MarshalIndent(session, "", " ") if err != nil { - return fmt.Errorf("runtime: marshal session: %w", err) + return fmt.Errorf("session: marshal session: %w", err) } payload = append(payload, '\n') target := s.filePath(session.ID) temp := target + ".tmp" if err := os.WriteFile(temp, payload, 0o644); err != nil { - return fmt.Errorf("runtime: write temp session: %w", err) + return fmt.Errorf("session: write temp session: %w", err) } if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("runtime: replace session file: %w", err) + return fmt.Errorf("session: replace session file: %w", err) } if err := os.Rename(temp, target); err != nil { - return fmt.Errorf("runtime: commit session file: %w", err) + return fmt.Errorf("session: commit session file: %w", err) } return nil } -func (s *JSONSessionStore) Load(ctx context.Context, id string) (Session, error) { +// Load 读取并反序列化指定 ID 的会话文件。 +func (s *JSONStore) Load(ctx context.Context, id string) (Session, error) { if err := ctx.Err(); err != nil { return Session{}, err } @@ -109,12 +118,13 @@ func (s *JSONSessionStore) Load(ctx context.Context, id string) (Session, error) var session Session if err := json.Unmarshal(data, &session); err != nil { - return Session{}, fmt.Errorf("runtime: decode session %s: %w", id, err) + return Session{}, fmt.Errorf("session: decode session %s: %w", id, err) } return session, nil } -func (s *JSONSessionStore) ListSummaries(ctx context.Context) ([]SessionSummary, error) { +// ListSummaries 列出所有会话摘要,并按 UpdatedAt 倒序返回。 +func (s *JSONStore) ListSummaries(ctx context.Context) ([]Summary, error) { if err := ctx.Err(); err != nil { return nil, err } @@ -123,15 +133,15 @@ func (s *JSONSessionStore) ListSummaries(ctx context.Context) ([]SessionSummary, defer s.mu.RUnlock() if err := os.MkdirAll(s.baseDir, 0o755); err != nil { - return nil, fmt.Errorf("runtime: create sessions dir: %w", err) + return nil, fmt.Errorf("session: create sessions dir: %w", err) } entries, err := os.ReadDir(s.baseDir) if err != nil { - return nil, fmt.Errorf("runtime: list sessions dir: %w", err) + return nil, fmt.Errorf("session: list sessions dir: %w", err) } - summaries := make([]SessionSummary, 0, len(entries)) + summaries := make([]Summary, 0, len(entries)) for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { continue @@ -148,7 +158,7 @@ func (s *JSONSessionStore) ListSummaries(ctx context.Context) ([]SessionSummary, continue } - var summary SessionSummary + var summary Summary if err := json.Unmarshal(data, &summary); err != nil { continue } @@ -165,18 +175,21 @@ func (s *JSONSessionStore) ListSummaries(ctx context.Context) ([]SessionSummary, return summaries, nil } -func (s *JSONSessionStore) filePath(id string) string { +// filePath 生成会话 ID 对应的 JSON 文件路径。 +func (s *JSONStore) filePath(id string) string { return filepath.Join(s.baseDir, id+".json") } -func newSession(title string) Session { - return newSessionWithWorkdir(title, "") +// New 创建一个默认标题策略的新会话对象。 +func New(title string) Session { + return NewWithWorkdir(title, "") } -func newSessionWithWorkdir(title string, workdir string) Session { +// NewWithWorkdir 创建一个包含运行目录的会话对象。 +func NewWithWorkdir(title string, workdir string) Session { now := time.Now() return Session{ - ID: newID("session"), + ID: NewID("session"), Title: sanitizeTitle(title), CreatedAt: now, UpdatedAt: now, @@ -185,6 +198,7 @@ func newSessionWithWorkdir(title string, workdir string) Session { } } +// sanitizeTitle 规范化会话标题:去空白、空标题回退默认值、超长截断。 func sanitizeTitle(title string) string { title = strings.TrimSpace(title) if title == "" { diff --git a/internal/runtime/session_test.go b/internal/session/store_test.go similarity index 87% rename from internal/runtime/session_test.go rename to internal/session/store_test.go index a82f1215..0e5e02d4 100644 --- a/internal/runtime/session_test.go +++ b/internal/session/store_test.go @@ -1,4 +1,4 @@ -package runtime +package session import ( "context" @@ -11,11 +11,11 @@ import ( "neo-code/internal/provider" ) -func TestJSONSessionStoreSaveLoadAndListSummaries(t *testing.T) { +func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONSessionStore(baseDir) + store := NewJSONStore(baseDir) older := &Session{ ID: "session-old", @@ -68,7 +68,7 @@ func TestJSONSessionStoreSaveLoadAndListSummaries(t *testing.T) { t.Fatalf("expected persisted session file to exclude workdir, got:\n%s", string(raw)) } - mustWriteRuntimeFile(t, filepath.Join(baseDir, sessionsDirName, "invalid.json"), "{invalid") + mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "invalid.json"), "{invalid") if err := os.MkdirAll(filepath.Join(baseDir, sessionsDirName, "directory"), 0o755); err != nil { t.Fatalf("mkdir stray directory: %v", err) } @@ -85,11 +85,11 @@ func TestJSONSessionStoreSaveLoadAndListSummaries(t *testing.T) { } } -func TestJSONSessionStoreErrors(t *testing.T) { +func TestJSONStoreErrors(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONSessionStore(baseDir) + store := NewJSONStore(baseDir) cancelledCtx, cancel := context.WithCancel(context.Background()) cancel() @@ -108,11 +108,11 @@ func TestJSONSessionStoreErrors(t *testing.T) { } } -func TestJSONSessionStoreCorruptedSessionBehaviors(t *testing.T) { +func TestJSONStoreCorruptedSessionBehaviors(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONSessionStore(baseDir) + store := NewJSONStore(baseDir) valid := &Session{ ID: "valid-session", @@ -125,7 +125,7 @@ func TestJSONSessionStoreCorruptedSessionBehaviors(t *testing.T) { t.Fatalf("Save valid session: %v", err) } - mustWriteRuntimeFile(t, filepath.Join(baseDir, sessionsDirName, "broken.json"), "{broken") + mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "broken.json"), "{broken") _, err := store.Load(context.Background(), "broken") if err == nil || !strings.Contains(err.Error(), "decode session broken") { @@ -141,7 +141,7 @@ func TestJSONSessionStoreCorruptedSessionBehaviors(t *testing.T) { } } -func TestJSONSessionStoreSaveInvalidBaseDir(t *testing.T) { +func TestJSONStoreSaveInvalidBaseDir(t *testing.T) { t.Parallel() tempDir := t.TempDir() @@ -150,7 +150,7 @@ func TestJSONSessionStoreSaveInvalidBaseDir(t *testing.T) { t.Fatalf("write base file: %v", err) } - store := NewJSONSessionStore(baseFile) + store := NewJSONStore(baseFile) err := store.Save(context.Background(), &Session{ ID: "session-x", Title: "Broken Save", @@ -162,7 +162,7 @@ func TestJSONSessionStoreSaveInvalidBaseDir(t *testing.T) { } } -func mustWriteRuntimeFile(t *testing.T, path string, content string) { +func mustWriteSessionFile(t *testing.T, path string, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) diff --git a/internal/tui/state.go b/internal/tui/state.go index 1f939dad..c65ecc63 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -10,7 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" ) type panel int @@ -32,7 +32,7 @@ const ( ) type UIState struct { - Sessions []agentruntime.SessionSummary + Sessions []agentsession.Summary ActiveSessionID string ActiveSessionTitle string InputText string @@ -142,7 +142,7 @@ func (d commandMenuDelegate) Render(w io.Writer, m list.Model, index int, item l } type sessionItem struct { - Summary agentruntime.SessionSummary + Summary agentsession.Summary Active bool } diff --git a/internal/tui/state/ui_state.go b/internal/tui/state/ui_state.go index ac6c4611..9fa071a3 100644 --- a/internal/tui/state/ui_state.go +++ b/internal/tui/state/ui_state.go @@ -1,6 +1,6 @@ package state -import agentruntime "neo-code/internal/runtime" +import agentsession "neo-code/internal/session" // Panel 定义 TUI 中可聚焦的主面板。 type Panel int @@ -24,7 +24,7 @@ const ( // UIState 保存顶层界面状态快照,仅作为数据容器使用。 type UIState struct { - Sessions []agentruntime.SessionSummary + Sessions []agentsession.Summary ActiveSessionID string ActiveSessionTitle string ActiveRunID string diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 6a741a06..255786b8 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -21,6 +21,7 @@ import ( "neo-code/internal/provider" providercatalog "neo-code/internal/provider/catalog" agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" "neo-code/internal/tools" ) @@ -28,15 +29,15 @@ type stubRuntime struct { runInputs []agentruntime.UserInput compactInputs []agentruntime.CompactInput events chan agentruntime.RuntimeEvent - sessions []agentruntime.SessionSummary - loads map[string]agentruntime.Session + sessions []agentsession.Summary + loads map[string]agentsession.Session runErr error compactErr error compactResult agentruntime.CompactResult listErr error loadErr error setWorkdirErr error - setResult *agentruntime.Session + setResult *agentsession.Session setCalls int resolveInputs []agentruntime.PermissionResolutionInput resolveErr error @@ -66,7 +67,7 @@ func (r *stubMarkdownRenderer) Render(content string, width int) (string, error) func newStubRuntime() *stubRuntime { return &stubRuntime{ events: make(chan agentruntime.RuntimeEvent, 16), - loads: map[string]agentruntime.Session{}, + loads: map[string]agentsession.Session{}, } } @@ -94,34 +95,34 @@ func (r *stubRuntime) CancelActiveRun() bool { return r.cancelResult } -func (r *stubRuntime) ListSessions(ctx context.Context) ([]agentruntime.SessionSummary, error) { +func (r *stubRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { if r.listErr != nil { return nil, r.listErr } - return append([]agentruntime.SessionSummary(nil), r.sessions...), nil + return append([]agentsession.Summary(nil), r.sessions...), nil } -func (r *stubRuntime) LoadSession(ctx context.Context, id string) (agentruntime.Session, error) { +func (r *stubRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { if r.loadErr != nil { - return agentruntime.Session{}, r.loadErr + return agentsession.Session{}, r.loadErr } if session, ok := r.loads[id]; ok { return session, nil } - return agentruntime.Session{}, nil + return agentsession.Session{}, nil } -func (r *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentruntime.Session, error) { +func (r *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { r.setCalls++ if r.setWorkdirErr != nil { - return agentruntime.Session{}, r.setWorkdirErr + return agentsession.Session{}, r.setWorkdirErr } if r.setResult != nil { return *r.setResult, nil } session, ok := r.loads[sessionID] if !ok { - session = agentruntime.Session{ID: sessionID} + session = agentsession.Session{ID: sessionID} } session.Workdir = strings.TrimSpace(workdir) r.loads[sessionID] = session @@ -250,7 +251,7 @@ func TestAppUpdateWorkspaceSlashCommands(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() sessionID := "session-workdir" - runtime.loads[sessionID] = agentruntime.Session{ID: sessionID, Workdir: t.TempDir()} + runtime.loads[sessionID] = agentsession.Session{ID: sessionID, Workdir: t.TempDir()} app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { @@ -327,7 +328,7 @@ func TestRunSessionWorkdirCommandBranches(t *testing.T) { t.Run("session workdir fallback uses current workdir when runtime returns empty", func(t *testing.T) { current := t.TempDir() runtime := newStubRuntime() - runtime.setResult = &agentruntime.Session{ID: "session-1", Workdir: ""} + runtime.setResult = &agentsession.Session{ID: "session-1", Workdir: ""} msg := runSessionWorkdirCommand(runtime, "session-1", current, "/cwd ./subdir")() result := msg.(sessionWorkdirResultMsg) if result.err != nil { @@ -342,7 +343,7 @@ func TestRunSessionWorkdirCommandBranches(t *testing.T) { current := t.TempDir() target := t.TempDir() runtime := newStubRuntime() - runtime.setResult = &agentruntime.Session{ID: "session-1", Workdir: target} + runtime.setResult = &agentsession.Session{ID: "session-1", Workdir: target} msg := runSessionWorkdirCommand(runtime, "session-1", current, "/cwd ./subdir")() result := msg.(sessionWorkdirResultMsg) if result.err != nil { @@ -775,7 +776,7 @@ func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { func TestAppHelpersAndRenderingSmoke(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() - now := agentruntime.Session{ + now := agentsession.Session{ ID: "session-1", Title: "Existing Session", Messages: []provider.Message{ @@ -783,7 +784,7 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { {Role: roleAssistant, Content: "hello"}, }, } - runtime.sessions = []agentruntime.SessionSummary{ + runtime.sessions = []agentsession.Summary{ {ID: now.ID, Title: now.Title, UpdatedAt: now.UpdatedAt}, } runtime.loads[now.ID] = now @@ -985,7 +986,7 @@ func TestTUIStandaloneHelpers(t *testing.T) { t.Fatalf("expected numeric helpers to work") } - sItem := sessionItem{Summary: agentruntime.SessionSummary{Title: "My Session"}} + sItem := sessionItem{Summary: agentsession.Summary{Title: "My Session"}} if sItem.FilterValue() != "my session" { t.Fatalf("unexpected session item filter value") } @@ -1207,8 +1208,8 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { { name: "session enter activates selected session", setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - runtime.sessions = []agentruntime.SessionSummary{{ID: "s1", Title: "One"}} - runtime.loads["s1"] = agentruntime.Session{ + runtime.sessions = []agentsession.Summary{{ID: "s1", Title: "One"}} + runtime.loads["s1"] = agentsession.Session{ ID: "s1", Title: "One", Messages: []provider.Message{{Role: roleAssistant, Content: "loaded"}}, From ffce7c3814e2280504081ee74a68deccb55ff1da Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 7 Apr 2026 18:04:04 +0800 Subject: [PATCH 3/8] =?UTF-8?q?test:=E8=A1=A5=E5=85=85=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/session/id_test.go | 41 ++++++++++++++++++++++++++ internal/session/store_test.go | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 internal/session/id_test.go diff --git a/internal/session/id_test.go b/internal/session/id_test.go new file mode 100644 index 00000000..8ef1bb51 --- /dev/null +++ b/internal/session/id_test.go @@ -0,0 +1,41 @@ +package session + +import ( + "strings" + "testing" +) + +func TestNewIDFormatAndUniqueness(t *testing.T) { + t.Parallel() + + id1 := NewID("session") + id2 := NewID("session") + + if !strings.HasPrefix(id1, "session_") || !strings.HasPrefix(id2, "session_") { + t.Fatalf("expected prefix session_, got %q and %q", id1, id2) + } + + hex1 := strings.TrimPrefix(id1, "session_") + hex2 := strings.TrimPrefix(id2, "session_") + + if len(hex1) != 16 || len(hex2) != 16 { + t.Fatalf("expected 16 hex chars, got %d and %d", len(hex1), len(hex2)) + } + for _, ch := range hex1 + hex2 { + if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) { + t.Fatalf("expected lowercase hex, got %q in ids %q %q", ch, id1, id2) + } + } + if id1 == id2 { + t.Fatalf("expected different ids, got identical %q", id1) + } +} + +func TestNewIDAllowsEmptyPrefix(t *testing.T) { + t.Parallel() + + id := NewID("") + if len(id) != 17 || id[0] != '_' { + t.Fatalf("expected format _<16hex>, got %q", id) + } +} diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 0e5e02d4..75161164 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -162,6 +162,60 @@ func TestJSONStoreSaveInvalidBaseDir(t *testing.T) { } } +func TestNewUsesDefaultWorkdirAndEmptyMessages(t *testing.T) { + t.Parallel() + + session := New("hello title") + + if session.ID == "" { + t.Fatalf("expected non-empty id") + } + if !strings.HasPrefix(session.ID, "session_") { + t.Fatalf("expected id with session_ prefix, got %q", session.ID) + } + if session.Title != "hello title" { + t.Fatalf("expected title %q, got %q", "hello title", session.Title) + } + if session.Workdir != "" { + t.Fatalf("expected empty workdir, got %q", session.Workdir) + } + if len(session.Messages) != 0 { + t.Fatalf("expected empty messages, got %+v", session.Messages) + } + if session.CreatedAt.IsZero() || session.UpdatedAt.IsZero() { + t.Fatalf("expected non-zero timestamps, got created=%v updated=%v", session.CreatedAt, session.UpdatedAt) + } + if session.UpdatedAt.Before(session.CreatedAt) { + t.Fatalf("expected UpdatedAt >= CreatedAt, got created=%v updated=%v", session.CreatedAt, session.UpdatedAt) + } +} + +func TestNewWithWorkdirTrimAndTitleSanitize(t *testing.T) { + t.Parallel() + + tooLong := strings.Repeat("中", 45) // rune 长度 > 40 + workdir := " /tmp/workdir " + + session := NewWithWorkdir(tooLong, workdir) + + if session.Workdir != "/tmp/workdir" { + t.Fatalf("expected trimmed workdir %q, got %q", "/tmp/workdir", session.Workdir) + } + if got := len([]rune(session.Title)); got != 40 { + t.Fatalf("expected title rune length 40, got %d (title=%q)", got, session.Title) + } +} + +func TestNewWithWorkdirFallsBackDefaultTitle(t *testing.T) { + t.Parallel() + + session := NewWithWorkdir(" \n\t ", "") + + if session.Title != "New Session" { + t.Fatalf("expected default title %q, got %q", "New Session", session.Title) + } +} + func mustWriteSessionFile(t *testing.T, path string, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { From 24a6c0822763fb416d41ce2ffadaca04e05203cb Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 7 Apr 2026 20:22:16 +0800 Subject: [PATCH 4/8] =?UTF-8?q?test:=E6=B7=BB=E5=8A=A0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/runtime/runtime_test.go | 205 +++++++++++++++++++++++++++++++ internal/session/id_test.go | 12 ++ internal/session/store_test.go | 153 +++++++++++++++++++++++ 3 files changed, 370 insertions(+) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index d010a908..af02608c 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -2822,3 +2822,208 @@ func TestPermissionEventViewPayloadMapping(t *testing.T) { t.Fatalf("unexpected resolved payload: %+v", resolvedPayload) } } + +func TestStreamAccumulatorBuildMessageRejectsMissingToolName(t *testing.T) { + t.Parallel() + + acc := newStreamAccumulator() + acc.accumulateToolCallStart(0, "call-1", "") + acc.accumulateToolCallDelta(0, "call-1", "{}") + + _, err := acc.buildMessage() + if err == nil || !containsError(err, "without name") { + t.Fatalf("expected missing tool name error, got %v", err) + } +} + +func TestLoadSessionReturnsStoreError(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + service := NewWithFactory(manager, nil, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + + _, err := service.LoadSession(context.Background(), "missing") + if err == nil || !containsError(err, "not found") { + t.Fatalf("expected load error, got %v", err) + } +} + +func TestSetSessionWorkdirReturnsStoreError(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + service := NewWithFactory(manager, nil, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + + _, err := service.SetSessionWorkdir(context.Background(), "missing", t.TempDir()) + if err == nil || !containsError(err, "not found") { + t.Fatalf("expected load error from SetSessionWorkdir, got %v", err) + } +} + +func TestSetSessionWorkdirReturnsResolveError(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + defaultWorkdir := t.TempDir() + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = defaultWorkdir + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + + store := newMemoryStore() + session := agentsession.New("set bad workdir") + session.ID = "session-set-bad-workdir" + store.sessions[session.ID] = cloneSession(session) + + service := NewWithFactory(manager, nil, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + _, err := service.SetSessionWorkdir(context.Background(), session.ID, filepath.Join(defaultWorkdir, "missing-dir")) + if err == nil || !containsError(err, "resolve workdir") { + t.Fatalf("expected resolve workdir error, got %v", err) + } +} + +func TestServiceRunFailsWhenInitialUserMessageSaveFails(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + baseStore := newMemoryStore() + store := &failingStore{ + Store: baseStore, + saveErr: errors.New("save failed on first write"), + failOnSave: 1, + } + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + err := service.Run(context.Background(), UserInput{ + RunID: "run-initial-save-fail", + Content: "hello", + }) + if err == nil || !containsError(err, "save failed on first write") { + t.Fatalf("expected initial save error, got %v", err) + } +} + +func TestServiceRunFailsWhenAssistantSaveFails(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + baseStore := newMemoryStore() + store := &failingStore{ + Store: baseStore, + saveErr: errors.New("save failed on assistant"), + failOnSave: 2, + } + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + scripted := &scriptedProvider{ + streams: [][]provider.StreamEvent{ + {provider.NewTextDeltaStreamEvent("assistant reply")}, + }, + } + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) + err := service.Run(context.Background(), UserInput{ + RunID: "run-assistant-save-fail", + Content: "hello", + }) + if err == nil || !containsError(err, "save failed on assistant") { + t.Fatalf("expected assistant save error, got %v", err) + } +} + +func TestHandleProviderStreamEventErrorBranches(t *testing.T) { + t.Parallel() + + acc := newStreamAccumulator() + + err := handleProviderStreamEvent( + provider.StreamEvent{Type: provider.StreamEventToolCallStart}, + acc, + nil, + nil, + ) + if err == nil || !containsError(err, "tool_call_start event payload is nil") { + t.Fatalf("expected tool_call_start payload error, got %v", err) + } + + err = handleProviderStreamEvent( + provider.StreamEvent{Type: provider.StreamEventToolCallDelta}, + acc, + nil, + nil, + ) + if err == nil || !containsError(err, "tool_call_delta event payload is nil") { + t.Fatalf("expected tool_call_delta payload error, got %v", err) + } + + err = handleProviderStreamEvent( + provider.StreamEvent{Type: provider.StreamEventMessageDone}, + acc, + nil, + nil, + ) + if err == nil || !containsError(err, "message_done event payload is nil") { + t.Fatalf("expected message_done payload error, got %v", err) + } +} + +func TestEmitDropsWhenChannelFullAndContextCanceled(t *testing.T) { + t.Parallel() + + service := &Service{ + events: make(chan RuntimeEvent, 1), + } + service.events <- RuntimeEvent{Type: EventAgentChunk} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + done := make(chan struct{}) + go func() { + service.emit(ctx, EventError, "run-id", "session-id", "payload") + close(done) + }() + + select { + case <-done: + case <-time.After(1 * time.Second): + t.Fatal("emit should return when channel is full and context is canceled") + } +} + +func TestCallProviderWithRetryReturnsCombinedForwardError(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + store := newMemoryStore() + + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error { + events <- provider.StreamEvent{Type: provider.StreamEventTextDelta} + return errors.New("provider chat failed") + }, + } + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) + + _, err := service.callProviderWithRetry( + context.Background(), + "run-forward-error", + "session-forward-error", + provider.ChatRequest{ + Model: "test-model", + SystemPrompt: "prompt", + Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}, + }, + ) + if err == nil || !containsError(err, "provider stream handling failed after provider error") { + t.Fatalf("expected combined forward/provider error, got %v", err) + } +} diff --git a/internal/session/id_test.go b/internal/session/id_test.go index 8ef1bb51..396779f8 100644 --- a/internal/session/id_test.go +++ b/internal/session/id_test.go @@ -39,3 +39,15 @@ func TestNewIDAllowsEmptyPrefix(t *testing.T) { t.Fatalf("expected format _<16hex>, got %q", id) } } + +func TestNewIDCompatibilityWrapper(t *testing.T) { + t.Parallel() + + id := newID("session") + if !strings.HasPrefix(id, "session_") { + t.Fatalf("expected compatibility wrapper to preserve prefix, got %q", id) + } + if len(strings.TrimPrefix(id, "session_")) != 16 { + t.Fatalf("expected compatibility wrapper to return 16 hex chars, got %q", id) + } +} diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 75161164..a886b73e 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -2,6 +2,8 @@ package session import ( "context" + "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -216,6 +218,157 @@ func TestNewWithWorkdirFallsBackDefaultTitle(t *testing.T) { } } +func TestNewStoreReturnsJSONStore(t *testing.T) { + t.Parallel() + + store := NewStore(t.TempDir()) + if store == nil { + t.Fatalf("expected non-nil store") + } +} + +func TestJSONStoreListSummariesReadDirFailure(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + store := NewJSONStore(baseDir) + + // 把 sessions 目录位置占成普通文件,触发 ReadDir 失败路径。 + sessionsPath := filepath.Join(baseDir, sessionsDirName) + if err := os.WriteFile(sessionsPath, []byte("not-a-dir"), 0o644); err != nil { + t.Fatalf("write %s: %v", sessionsPath, err) + } + + _, err := store.ListSummaries(context.Background()) + if err == nil || !strings.Contains(err.Error(), "create sessions dir") { + t.Fatalf("expected create sessions dir error, got %v", err) + } +} + +func TestJSONStoreListSummariesContextCanceledDuringIteration(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + store := NewJSONStore(baseDir) + + for i := 0; i < 10; i++ { + s := &Session{ + ID: "session-iter-" + strings.Repeat("x", i+1), + Title: "iter", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := store.Save(context.Background(), s); err != nil { + t.Fatalf("save session %d: %v", i, err) + } + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := store.ListSummaries(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context canceled, got %v", err) + } +} + +func TestJSONStoreLoadDecodeErrorWithNonJSONPayload(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + store := NewJSONStore(baseDir) + + mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "decode-bad.json"), "{not-json") + + _, err := store.Load(context.Background(), "decode-bad") + if err == nil || !strings.Contains(err.Error(), "decode session decode-bad") { + t.Fatalf("expected decode session error, got %v", err) + } +} + +func TestJSONStoreListSummariesSkipsUnreadableAndMalformedEntries(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + store := NewJSONStore(baseDir) + + valid := &Session{ + ID: "valid-summary", + Title: "Valid", + CreatedAt: time.Now().Add(-time.Minute), + UpdatedAt: time.Now(), + } + if err := store.Save(context.Background(), valid); err != nil { + t.Fatalf("save valid session: %v", err) + } + + mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "malformed.json"), "{malformed") + mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "empty-id.json"), `{"id":" ","title":"x"}`) + + summaries, err := store.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() error: %v", err) + } + if len(summaries) != 1 || summaries[0].ID != valid.ID { + t.Fatalf("expected only valid summary, got %+v", summaries) + } +} + +func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + store := NewJSONStore(baseDir) + + session := &Session{ + ID: "persist-full-fields", + Title: "Persist Fields", + Provider: "openai", + Model: "gpt-4.1", + CreatedAt: time.Now().Add(-time.Hour), + UpdatedAt: time.Now(), + Messages: []provider.Message{ + {Role: provider.RoleUser, Content: "hello"}, + { + Role: provider.RoleAssistant, + Content: "calling tool", + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "webfetch", Arguments: `{"url":"https://example.com"}`}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "ok"}, + }, + } + + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("save session: %v", err) + } + + rawPath := filepath.Join(baseDir, sessionsDirName, session.ID+".json") + raw, err := os.ReadFile(rawPath) + if err != nil { + t.Fatalf("read raw file: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("decode raw json: %v", err) + } + + if decoded["provider"] != "openai" { + t.Fatalf("expected provider persisted, got %+v", decoded["provider"]) + } + if decoded["model"] != "gpt-4.1" { + t.Fatalf("expected model persisted, got %+v", decoded["model"]) + } + if _, ok := decoded["messages"]; !ok { + t.Fatalf("expected messages field persisted, got %+v", decoded) + } + if _, ok := decoded["workdir"]; ok { + t.Fatalf("expected workdir not persisted, got %+v", decoded) + } +} + func mustWriteSessionFile(t *testing.T, path string, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { From f1793af08d6515092ac1db413010556809abc098 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 7 Apr 2026 20:32:16 +0800 Subject: [PATCH 5/8] =?UTF-8?q?refator:=E6=8B=86=E5=88=86=E5=87=BAsession?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- docs/session-persistence-design.md | 30 ++++++++ internal/app/bootstrap.go | 3 +- internal/runtime/compact.go | 9 +-- internal/runtime/id.go | 12 ---- internal/runtime/runtime.go | 37 +++++----- internal/runtime/runtime_test.go | 71 ++++++++++--------- internal/runtime/workdir_branch_test.go | 4 +- internal/session/id.go | 18 +++++ .../{runtime/session.go => session/store.go} | 68 +++++++++++------- .../session_test.go => session/store_test.go} | 24 +++---- internal/tui/state.go | 6 +- internal/tui/state/ui_state.go | 4 +- internal/tui/update_test.go | 41 +++++------ 14 files changed, 195 insertions(+), 136 deletions(-) create mode 100644 docs/session-persistence-design.md delete mode 100644 internal/runtime/id.go create mode 100644 internal/session/id.go rename internal/{runtime/session.go => session/store.go} (59%) rename internal/{runtime/session_test.go => session/store_test.go} (88%) diff --git a/README.md b/README.md index d710be7c..33fdaa73 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,8 @@ go run ./cmd/neocode - **`internal/provider/catalog`** — 模型发现、catalog 缓存与后台刷新 - **`internal/provider/selection`** — provider/model 选择与配置同步 - **`internal/provider/builtin`** — 内建 driver 注册 -- **`internal/runtime`** — ReAct 主循环、事件流、会话管理 +- **`internal/runtime`** — ReAct 主循环与事件流编排(不直接承载会话存储实现;不再导出会话模型与存储类型) +- **`internal/session`** — 会话模型、会话存储抽象与 JSON 持久化实现(统一对外暴露 `Session` / `Summary` / `Store`) - **`internal/tools`** — 工具注册表与具体工具实现 - **`internal/tui`** — 终端 UI、交互体验、事件桥接 - **`internal/app`** — 应用装配与依赖注入 @@ -116,6 +117,7 @@ go run ./cmd/neocode │ │ ├── catalog # 模型发现与缓存 │ │ └── selection # provider/model 选择服务 │ ├── runtime # ReAct 循环与事件流 +│ ├── session # 会话模型与持久化 │ ├── tools # 工具系统 │ └── tui # 终端 UI └── README.md diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md new file mode 100644 index 00000000..e66f7800 --- /dev/null +++ b/docs/session-persistence-design.md @@ -0,0 +1,30 @@ +# Session 持久化设计 + +## 模块职责与收口边界 +- `internal/session`:承载会话领域模型、存储抽象与 JSON 持久化实现,是唯一的会话持久化实现归属层 +- `internal/runtime`:只依赖 `internal/session` 提供的抽象与模型,负责会话保存时机与主循环编排,不再维护会话存储实现细节 +- `internal/tui`:仅消费 runtime 暴露的会话数据,不直接执行会话持久化 + +## 存储策略 +NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优先、易于调试和跨平台可移植。 + +## 数据模型 +- `Session`:完整消息历史以及 `id`、`title`、`updated_at` 等元信息 +- `Summary`:用于侧边栏的轻量摘要结构(原 `SessionSummary` 命名已统一收口为 `Summary`) + +## 加载策略 +- `ListSummaries` 只读取渲染侧边栏所需的基础信息 +- `Load` 仅在用户真正进入某个会话时读取完整消息历史 +- `Save` 通过临时文件原子写入完整 Session + +## 命名策略 +- 新会话默认展示为 `New Session` +- 一旦持久化,runtime 会根据首轮用户消息生成简短标题 + +## 并发约束 +- `internal/session` 中的 Store 实现必须自行保护共享访问 +- 真正的保存时机由 runtime 决定,TUI 不负责直接触发磁盘写入 + +## 兼容性与演进说明 +- 会话持久化能力已从 runtime 侧实现中彻底收口到 `internal/session` +- 新增会话存储实现时,应优先在 `internal/session` 内扩展并通过接口注入 runtime,避免跨层实现 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index dd0b8436..6d2892b3 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -12,6 +12,7 @@ import ( providercatalog "neo-code/internal/provider/catalog" agentruntime "neo-code/internal/runtime" "neo-code/internal/security" + agentsession "neo-code/internal/session" "neo-code/internal/tools" "neo-code/internal/tools/bash" "neo-code/internal/tools/filesystem" @@ -62,7 +63,7 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { return nil, err } - sessionStore := agentruntime.NewSessionStore(loader.BaseDir()) + sessionStore := agentsession.NewStore(loader.BaseDir()) runtimeSvc := agentruntime.NewWithFactory( manager, toolManager, diff --git a/internal/runtime/compact.go b/internal/runtime/compact.go index 9798d250..26487f33 100644 --- a/internal/runtime/compact.go +++ b/internal/runtime/compact.go @@ -9,6 +9,7 @@ import ( "neo-code/internal/config" contextcompact "neo-code/internal/context/compact" providertypes "neo-code/internal/provider/types" + agentsession "neo-code/internal/session" ) // CompactInput 描述一次手动 compact 请求所需的最小输入。 @@ -83,10 +84,10 @@ func (s *Service) Compact(ctx context.Context, input CompactInput) (CompactResul func (s *Service) runCompactForSession( ctx context.Context, runID string, - session Session, + session agentsession.Session, cfg config.Config, failOnError bool, -) (Session, contextcompact.Result, error) { +) (agentsession.Session, contextcompact.Result, error) { runner := s.compactRunner if runner == nil { var err error @@ -155,7 +156,7 @@ func (s *Service) runCompactForSession( } // defaultCompactRunner 为手动 compact 选择摘要生成器并构造默认 runner。 -func (s *Service) defaultCompactRunner(session Session, cfg config.Config) (contextcompact.Runner, error) { +func (s *Service) defaultCompactRunner(session agentsession.Session, cfg config.Config) (contextcompact.Runner, error) { resolvedProvider, model, err := resolveCompactProviderSelection(session, cfg) if err != nil { return nil, err @@ -164,7 +165,7 @@ func (s *Service) defaultCompactRunner(session Session, cfg config.Config) (cont } // resolveCompactProviderSelection 优先复用会话记录的 provider/model,缺失时再回退当前配置。 -func resolveCompactProviderSelection(session Session, cfg config.Config) (config.ResolvedProviderConfig, string, error) { +func resolveCompactProviderSelection(session agentsession.Session, cfg config.Config) (config.ResolvedProviderConfig, string, error) { sessionProvider := strings.TrimSpace(session.Provider) sessionModel := strings.TrimSpace(session.Model) if sessionProvider != "" && sessionModel != "" { diff --git a/internal/runtime/id.go b/internal/runtime/id.go deleted file mode 100644 index b036fbea..00000000 --- a/internal/runtime/id.go +++ /dev/null @@ -1,12 +0,0 @@ -package runtime - -import ( - "crypto/rand" - "encoding/hex" -) - -func newID(prefix string) string { - buf := make([]byte, 8) - _, _ = rand.Read(buf) - return prefix + "_" + hex.EncodeToString(buf) -} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 7b727e08..a6352f22 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -18,6 +18,7 @@ import ( contextcompact "neo-code/internal/context/compact" "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" + agentsession "neo-code/internal/session" "neo-code/internal/tools" ) @@ -121,9 +122,9 @@ type Runtime interface { ResolvePermission(ctx context.Context, input PermissionResolutionInput) error CancelActiveRun() bool Events() <-chan RuntimeEvent - ListSessions(ctx context.Context) ([]SessionSummary, error) - LoadSession(ctx context.Context, id string) (Session, error) - SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (Session, error) + ListSessions(ctx context.Context) ([]agentsession.Summary, error) + LoadSession(ctx context.Context, id string) (agentsession.Session, error) + SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) } type UserInput struct { @@ -139,7 +140,7 @@ type ProviderFactory interface { type Service struct { configManager *config.Manager // 配置管理器,提供当前选中的 provider、model、workdir 等配置读取能力。 - sessionStore Store // 会话持久化接口,负责保存和加载聊天会话。 + sessionStore agentsession.Store // 会话持久化接口,负责保存和加载聊天会话。 toolManager tools.Manager // 工具管理器,统一工具 schema 暴露与执行入口。 providerFactory ProviderFactory // Provider 工厂接口,根据配置动态创建具体的 provider 实例。 contextBuilder agentcontext.Builder // 上下文构建器,负责组装 system prompt 与本轮发给模型的消息上下文。 @@ -155,7 +156,7 @@ type Service struct { func NewWithFactory( configManager *config.Manager, toolManager tools.Manager, - sessionStore Store, + sessionStore agentsession.Store, providerFactory ProviderFactory, contextBuilder agentcontext.Builder, ) *Service { @@ -373,35 +374,35 @@ func (s *Service) Events() <-chan RuntimeEvent { return s.events } -func (s *Service) ListSessions(ctx context.Context) ([]SessionSummary, error) { +func (s *Service) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { return s.sessionStore.ListSummaries(ctx) } -func (s *Service) LoadSession(ctx context.Context, id string) (Session, error) { +func (s *Service) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { session, err := s.sessionStore.Load(ctx, id) if err != nil { - return Session{}, err + return agentsession.Session{}, err } session.Workdir = s.sessionWorkdir(id, session.Workdir) return session, nil } -func (s *Service) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (Session, error) { +func (s *Service) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { sessionID = strings.TrimSpace(sessionID) if sessionID == "" { - return Session{}, errors.New("runtime: session id is empty") + return agentsession.Session{}, errors.New("runtime: session id is empty") } session, err := s.sessionStore.Load(ctx, sessionID) if err != nil { - return Session{}, err + return agentsession.Session{}, err } session.Workdir = s.sessionWorkdir(sessionID, session.Workdir) cfg := s.configManager.Get() resolved, err := resolveWorkdirForSession(cfg.Workdir, session.Workdir, workdir) if err != nil { - return Session{}, err + return agentsession.Session{}, err } if session.Workdir == resolved { return session, nil @@ -440,22 +441,22 @@ func (s *Service) loadOrCreateSession( title string, defaultWorkdir string, requestedWorkdir string, -) (Session, error) { +) (agentsession.Session, error) { if strings.TrimSpace(sessionID) == "" { sessionWorkdir, err := resolveWorkdirForSession(defaultWorkdir, "", requestedWorkdir) if err != nil { - return Session{}, err + return agentsession.Session{}, err } - session := newSessionWithWorkdir(title, sessionWorkdir) + session := agentsession.NewWithWorkdir(title, sessionWorkdir) s.setSessionWorkdir(session.ID, sessionWorkdir) if err := s.sessionStore.Save(ctx, &session); err != nil { - return Session{}, err + return agentsession.Session{}, err } return session, nil } session, err := s.sessionStore.Load(ctx, sessionID) if err != nil { - return Session{}, err + return agentsession.Session{}, err } session.Workdir = s.sessionWorkdir(sessionID, session.Workdir) if strings.TrimSpace(requestedWorkdir) == "" && strings.TrimSpace(session.Workdir) != "" { @@ -464,7 +465,7 @@ func (s *Service) loadOrCreateSession( resolved, err := resolveWorkdirForSession(defaultWorkdir, session.Workdir, requestedWorkdir) if err != nil { - return Session{}, err + return agentsession.Session{}, err } if session.Workdir == resolved { return session, nil diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 0fe9f86a..242a59d3 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -16,16 +16,17 @@ import ( "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" "neo-code/internal/security" + agentsession "neo-code/internal/session" "neo-code/internal/tools" ) type memoryStore struct { - sessions map[string]Session + sessions map[string]agentsession.Session saves int } type failingStore struct { - Store + agentsession.Store saveErr error failOnSave int saveCalls int @@ -33,10 +34,10 @@ type failingStore struct { } func newMemoryStore() *memoryStore { - return &memoryStore{sessions: map[string]Session{}} + return &memoryStore{sessions: map[string]agentsession.Session{}} } -func (s *failingStore) Save(ctx context.Context, session *Session) error { +func (s *failingStore) Save(ctx context.Context, session *agentsession.Session) error { s.saveCalls++ if s.failOnSave > 0 && s.saveCalls == s.failOnSave { return s.saveErr @@ -50,7 +51,7 @@ func (s *failingStore) Save(ctx context.Context, session *Session) error { return s.Store.Save(ctx, session) } -func (s *memoryStore) Save(ctx context.Context, session *Session) error { +func (s *memoryStore) Save(ctx context.Context, session *agentsession.Session) error { if err := ctx.Err(); err != nil { return err } @@ -62,24 +63,24 @@ func (s *memoryStore) Save(ctx context.Context, session *Session) error { return nil } -func (s *memoryStore) Load(ctx context.Context, id string) (Session, error) { +func (s *memoryStore) Load(ctx context.Context, id string) (agentsession.Session, error) { if err := ctx.Err(); err != nil { - return Session{}, err + return agentsession.Session{}, err } session, ok := s.sessions[id] if !ok { - return Session{}, errors.New("not found") + return agentsession.Session{}, errors.New("not found") } return cloneSession(session), nil } -func (s *memoryStore) ListSummaries(ctx context.Context) ([]SessionSummary, error) { +func (s *memoryStore) ListSummaries(ctx context.Context) ([]agentsession.Summary, error) { if err := ctx.Err(); err != nil { return nil, err } - summaries := make([]SessionSummary, 0, len(s.sessions)) + summaries := make([]agentsession.Summary, 0, len(s.sessions)) for _, session := range s.sessions { - summaries = append(summaries, SessionSummary{ + summaries = append(summaries, agentsession.Summary{ ID: session.ID, Title: session.Title, CreatedAt: session.CreatedAt, @@ -607,7 +608,7 @@ func TestServiceRunDelegatesToContextBuilder(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("memory reject") + session := agentsession.New("memory reject") session.ID = "session-memory-reject" store.sessions[session.ID] = cloneSession(session) registry := tools.NewRegistry() @@ -748,7 +749,7 @@ func TestServiceRunDefaultBuilderUsesToolManagerMicroCompactPolicies(t *testing. registry.Register(&stubTool{name: "bash", content: "default"}) registry.Register(&stubTool{name: "webfetch", content: "default"}) - session := newSession("preserve history") + session := agentsession.New("preserve history") session.ID = "session-preserve-history" session.Messages = []providertypes.Message{ {Role: providertypes.RoleUser, Content: "older user"}, @@ -811,7 +812,7 @@ func TestServiceRunDefaultBuilderUsesGenericToolManagerMicroCompactPolicies(t *t }, } - session := newSession("preserve history by manager") + session := agentsession.New("preserve history by manager") session.ID = "session-preserve-history-manager" session.Messages = []providertypes.Message{ {Role: providertypes.RoleUser, Content: "older user"}, @@ -880,7 +881,7 @@ func TestServiceRunFailurePreservesExistingSessionProviderAndModel(t *testing.T) } store := newMemoryStore() - session := newSession("preserve-metadata") + session := agentsession.New("preserve-metadata") session.ID = "session-preserve-metadata" session.Provider = config.OpenAIName session.Model = "openai-original-model" @@ -980,7 +981,7 @@ func TestServiceRunWaitsForPermissionResolutionAndContinues(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("memory reject") + session := agentsession.New("memory reject") session.ID = "session-memory-reject" store.sessions[session.ID] = cloneSession(session) registry := tools.NewRegistry() @@ -1171,7 +1172,7 @@ func TestServiceRunEmitsRememberScopeWhenSessionRejectMemoryHits(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("memory reject") + session := agentsession.New("memory reject") session.ID = "session-memory-reject" store.sessions[session.ID] = cloneSession(session) registry := tools.NewRegistry() @@ -1311,7 +1312,7 @@ func TestServiceRunErrorPaths(t *testing.T) { provider *scriptedProvider factoryErr error registerTool *stubTool - seedSession *Session + seedSession *agentsession.Session expectErr string expectEvents []EventType assert func(t *testing.T, store *memoryStore, provider *scriptedProvider, tool *stubTool) @@ -1375,11 +1376,11 @@ func TestServiceRunErrorPaths(t *testing.T) { {providertypes.NewTextDeltaStreamEvent("resumed")}, }, }, - seedSession: &Session{ + seedSession: &agentsession.Session{ ID: "existing-session", Title: "Resume Me", - CreatedAt: newSession("seed").CreatedAt, - UpdatedAt: newSession("seed").UpdatedAt, + CreatedAt: agentsession.New("seed").CreatedAt, + UpdatedAt: agentsession.New("seed").UpdatedAt, Messages: []providertypes.Message{ {Role: "user", Content: "earlier"}, }, @@ -1869,7 +1870,7 @@ func TestServiceRunToolTimeoutIsNotCancellation(t *testing.T) { func TestServiceCompactManualAppliesAndPersists(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("manual") + session := agentsession.New("manual") session.ID = "session-manual" session.Messages = []providertypes.Message{ {Role: providertypes.RoleUser, Content: "older"}, @@ -1927,7 +1928,7 @@ func TestServiceCompactManualAppliesAndPersists(t *testing.T) { func TestServiceCompactManualFailureReturnsError(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("manual-fail") + session := agentsession.New("manual-fail") session.ID = "session-manual-fail" session.Messages = []providertypes.Message{ {Role: providertypes.RoleUser, Content: "older"}, @@ -1981,7 +1982,7 @@ func TestServiceCompactUsesSessionProviderAndModelWhenPresent(t *testing.T) { } store := newMemoryStore() - session := newSession("manual-provider") + session := agentsession.New("manual-provider") session.ID = "session-manual-provider" session.Provider = config.OpenAIName session.Model = "session-model" @@ -2055,7 +2056,7 @@ func TestServiceCompactFallsBackToCurrentProviderWhenSessionMetadataMissing(t *t } store := newMemoryStore() - session := newSession("manual-fallback") + session := agentsession.New("manual-fallback") session.ID = "session-manual-fallback" session.Messages = []providertypes.Message{ {Role: providertypes.RoleUser, Content: "older"}, @@ -2112,7 +2113,7 @@ func TestServiceCompactFallsBackToCurrentProviderWhenSessionMetadataMissing(t *t func TestServiceManualCompactThenRunContinuesToolRound(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("manual-continue") + session := agentsession.New("manual-continue") session.ID = "session-manual-continue" session.Messages = []providertypes.Message{ {Role: providertypes.RoleUser, Content: "legacy request"}, @@ -2195,7 +2196,7 @@ func TestServiceManualCompactThenRunContinuesToolRound(t *testing.T) { func TestServiceSerializesRunAndCompact(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("serialized") + session := agentsession.New("serialized") session.ID = "session-serialized" store.sessions[session.ID] = cloneSession(session) @@ -2291,7 +2292,7 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { t.Fatalf("expected events channel") } - session := newSession("List Me") + session := agentsession.New("List Me") store.sessions[session.ID] = cloneSession(session) summaries, err := service.ListSessions(context.Background()) @@ -2310,7 +2311,7 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { t.Fatalf("expected loaded session %q, got %q", session.ID, loaded.ID) } - sessionStore := NewSessionStore(t.TempDir()) + sessionStore := agentsession.NewStore(t.TempDir()) if sessionStore == nil { t.Fatalf("expected JSON session store") } @@ -2328,7 +2329,7 @@ func TestServiceRunUsesSessionWorkdirForContextAndTools(t *testing.T) { } store := newMemoryStore() - session := newSessionWithWorkdir("Session Workdir", sessionWorkdir) + session := agentsession.NewWithWorkdir("Session Workdir", sessionWorkdir) store.sessions[session.ID] = cloneSession(session) tool := &stubTool{name: "filesystem_edit", content: "ok"} @@ -2417,7 +2418,7 @@ func TestServiceSetSessionWorkdir(t *testing.T) { } store := newMemoryStore() - session := newSession("set workdir") + session := agentsession.New("set workdir") store.sessions[session.ID] = cloneSession(session) registry := tools.NewRegistry() registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) @@ -2548,7 +2549,7 @@ func restoreRuntimeEnv(t *testing.T, key string) { }) } -func onlySession(t *testing.T, store *memoryStore) Session { +func onlySession(t *testing.T, store *memoryStore) agentsession.Session { t.Helper() if len(store.sessions) != 1 { t.Fatalf("expected exactly 1 session, got %d", len(store.sessions)) @@ -2556,7 +2557,7 @@ func onlySession(t *testing.T, store *memoryStore) Session { for _, session := range store.sessions { return session } - return Session{} + return agentsession.Session{} } func resolvedProviderForTests(cfg config.Config, providerName string) (config.ResolvedProviderConfig, error) { @@ -2613,7 +2614,7 @@ func assertEventsRunID(t *testing.T, events []RuntimeEvent, runID string) { } } -func cloneSession(session Session) Session { +func cloneSession(session agentsession.Session) agentsession.Session { cloned := session cloned.Messages = append([]providertypes.Message(nil), session.Messages...) return cloned @@ -2698,7 +2699,7 @@ func TestServiceSetSessionWorkdirNoopDoesNotSave(t *testing.T) { store := newMemoryStore() target := t.TempDir() - session := newSessionWithWorkdir("noop", target) + session := agentsession.NewWithWorkdir("noop", target) store.sessions[session.ID] = cloneSession(session) registry := tools.NewRegistry() registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) diff --git a/internal/runtime/workdir_branch_test.go b/internal/runtime/workdir_branch_test.go index 0edc39ef..266fed5e 100644 --- a/internal/runtime/workdir_branch_test.go +++ b/internal/runtime/workdir_branch_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + agentsession "neo-code/internal/session" ) func TestSessionWorkdirKeyAndMemoryMap(t *testing.T) { @@ -82,7 +84,7 @@ func TestLoadSessionUsesFallbackWorkdirWhenMemoryMissing(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() - session := newSession("fallback") + session := agentsession.New("fallback") session.Workdir = t.TempDir() store.sessions[session.ID] = cloneSession(session) diff --git a/internal/session/id.go b/internal/session/id.go new file mode 100644 index 00000000..dc2e84e9 --- /dev/null +++ b/internal/session/id.go @@ -0,0 +1,18 @@ +package session + +import ( + "crypto/rand" + "encoding/hex" +) + +// NewID 生成带前缀的随机 ID,格式为 "_<16hex>"。 +func NewID(prefix string) string { + buf := make([]byte, 8) + _, _ = rand.Read(buf) + return prefix + "_" + hex.EncodeToString(buf) +} + +// newID 保留为内部兼容入口,后续代码请优先使用 NewID。 +func newID(prefix string) string { + return NewID(prefix) +} diff --git a/internal/runtime/session.go b/internal/session/store.go similarity index 59% rename from internal/runtime/session.go rename to internal/session/store.go index 24378d28..824a8228 100644 --- a/internal/runtime/session.go +++ b/internal/session/store.go @@ -1,4 +1,4 @@ -package runtime +package session import ( "context" @@ -17,6 +17,8 @@ import ( const sessionsDirName = "sessions" +// Session 表示单个会话的持久化模型,包含基础元数据与消息历史。 +// Provider / Model 用于在 compact 等流程中优先复用会话最近一次成功运行的模型配置。 type Session struct { ID string `json:"id"` Title string `json:"title"` @@ -30,71 +32,78 @@ type Session struct { Messages []providertypes.Message `json:"messages"` } -type SessionSummary struct { +// Summary 表示会话列表视图所需的轻量摘要信息。 +type Summary struct { ID string `json:"id"` Title string `json:"title"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +// Store 定义会话持久化抽象。 type Store interface { Save(ctx context.Context, session *Session) error Load(ctx context.Context, id string) (Session, error) - ListSummaries(ctx context.Context) ([]SessionSummary, error) + ListSummaries(ctx context.Context) ([]Summary, error) } -type JSONSessionStore struct { +// JSONStore 是基于 JSON 文件的会话存储实现。 +type JSONStore struct { mu sync.RWMutex baseDir string } -func NewJSONSessionStore(baseDir string) *JSONSessionStore { - return &JSONSessionStore{ +// NewJSONStore 创建 JSONStore,实际会话目录为 {baseDir}/sessions。 +func NewJSONStore(baseDir string) *JSONStore { + return &JSONStore{ baseDir: filepath.Join(baseDir, sessionsDirName), } } -func NewSessionStore(baseDir string) *JSONSessionStore { - return NewJSONSessionStore(baseDir) +// NewStore 返回默认会话存储实现(当前为 JSONStore)。 +func NewStore(baseDir string) *JSONStore { + return NewJSONStore(baseDir) } -func (s *JSONSessionStore) Save(ctx context.Context, session *Session) error { +// Save 持久化会话到 JSON 文件,采用临时文件 + 原子替换策略。 +func (s *JSONStore) Save(ctx context.Context, session *Session) error { if err := ctx.Err(); err != nil { return err } if session == nil { - return errors.New("runtime: session is nil") + return errors.New("session: session is nil") } s.mu.Lock() defer s.mu.Unlock() if err := os.MkdirAll(s.baseDir, 0o755); err != nil { - return fmt.Errorf("runtime: create sessions dir: %w", err) + return fmt.Errorf("session: create sessions dir: %w", err) } payload, err := json.MarshalIndent(session, "", " ") if err != nil { - return fmt.Errorf("runtime: marshal session: %w", err) + return fmt.Errorf("session: marshal session: %w", err) } payload = append(payload, '\n') target := s.filePath(session.ID) temp := target + ".tmp" if err := os.WriteFile(temp, payload, 0o644); err != nil { - return fmt.Errorf("runtime: write temp session: %w", err) + return fmt.Errorf("session: write temp session: %w", err) } if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("runtime: replace session file: %w", err) + return fmt.Errorf("session: replace session file: %w", err) } if err := os.Rename(temp, target); err != nil { - return fmt.Errorf("runtime: commit session file: %w", err) + return fmt.Errorf("session: commit session file: %w", err) } return nil } -func (s *JSONSessionStore) Load(ctx context.Context, id string) (Session, error) { +// Load 读取并反序列化指定 ID 的会话文件。 +func (s *JSONStore) Load(ctx context.Context, id string) (Session, error) { if err := ctx.Err(); err != nil { return Session{}, err } @@ -109,12 +118,13 @@ func (s *JSONSessionStore) Load(ctx context.Context, id string) (Session, error) var session Session if err := json.Unmarshal(data, &session); err != nil { - return Session{}, fmt.Errorf("runtime: decode session %s: %w", id, err) + return Session{}, fmt.Errorf("session: decode session %s: %w", id, err) } return session, nil } -func (s *JSONSessionStore) ListSummaries(ctx context.Context) ([]SessionSummary, error) { +// ListSummaries 列出所有会话摘要,并按 UpdatedAt 倒序返回。 +func (s *JSONStore) ListSummaries(ctx context.Context) ([]Summary, error) { if err := ctx.Err(); err != nil { return nil, err } @@ -123,15 +133,15 @@ func (s *JSONSessionStore) ListSummaries(ctx context.Context) ([]SessionSummary, defer s.mu.RUnlock() if err := os.MkdirAll(s.baseDir, 0o755); err != nil { - return nil, fmt.Errorf("runtime: create sessions dir: %w", err) + return nil, fmt.Errorf("session: create sessions dir: %w", err) } entries, err := os.ReadDir(s.baseDir) if err != nil { - return nil, fmt.Errorf("runtime: list sessions dir: %w", err) + return nil, fmt.Errorf("session: list sessions dir: %w", err) } - summaries := make([]SessionSummary, 0, len(entries)) + summaries := make([]Summary, 0, len(entries)) for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { continue @@ -148,7 +158,7 @@ func (s *JSONSessionStore) ListSummaries(ctx context.Context) ([]SessionSummary, continue } - var summary SessionSummary + var summary Summary if err := json.Unmarshal(data, &summary); err != nil { continue } @@ -165,18 +175,21 @@ func (s *JSONSessionStore) ListSummaries(ctx context.Context) ([]SessionSummary, return summaries, nil } -func (s *JSONSessionStore) filePath(id string) string { +// filePath 生成会话 ID 对应的 JSON 文件路径。 +func (s *JSONStore) filePath(id string) string { return filepath.Join(s.baseDir, id+".json") } -func newSession(title string) Session { - return newSessionWithWorkdir(title, "") +// New 创建一个默认标题策略的新会话对象。 +func New(title string) Session { + return NewWithWorkdir(title, "") } -func newSessionWithWorkdir(title string, workdir string) Session { +// NewWithWorkdir 创建一个包含运行目录的会话对象。 +func NewWithWorkdir(title string, workdir string) Session { now := time.Now() return Session{ - ID: newID("session"), + ID: NewID("session"), Title: sanitizeTitle(title), CreatedAt: now, UpdatedAt: now, @@ -185,6 +198,7 @@ func newSessionWithWorkdir(title string, workdir string) Session { } } +// sanitizeTitle 规范化会话标题:去空白、空标题回退默认值、超长截断。 func sanitizeTitle(title string) string { title = strings.TrimSpace(title) if title == "" { diff --git a/internal/runtime/session_test.go b/internal/session/store_test.go similarity index 88% rename from internal/runtime/session_test.go rename to internal/session/store_test.go index 06faa257..985cc809 100644 --- a/internal/runtime/session_test.go +++ b/internal/session/store_test.go @@ -1,4 +1,4 @@ -package runtime +package session import ( "context" @@ -11,11 +11,11 @@ import ( providertypes "neo-code/internal/provider/types" ) -func TestJSONSessionStoreSaveLoadAndListSummaries(t *testing.T) { +func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONSessionStore(baseDir) + store := NewJSONStore(baseDir) older := &Session{ ID: "session-old", @@ -68,7 +68,7 @@ func TestJSONSessionStoreSaveLoadAndListSummaries(t *testing.T) { t.Fatalf("expected persisted session file to exclude workdir, got:\n%s", string(raw)) } - mustWriteRuntimeFile(t, filepath.Join(baseDir, sessionsDirName, "invalid.json"), "{invalid") + mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "invalid.json"), "{invalid") if err := os.MkdirAll(filepath.Join(baseDir, sessionsDirName, "directory"), 0o755); err != nil { t.Fatalf("mkdir stray directory: %v", err) } @@ -85,11 +85,11 @@ func TestJSONSessionStoreSaveLoadAndListSummaries(t *testing.T) { } } -func TestJSONSessionStoreErrors(t *testing.T) { +func TestJSONStoreErrors(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONSessionStore(baseDir) + store := NewJSONStore(baseDir) cancelledCtx, cancel := context.WithCancel(context.Background()) cancel() @@ -108,11 +108,11 @@ func TestJSONSessionStoreErrors(t *testing.T) { } } -func TestJSONSessionStoreCorruptedSessionBehaviors(t *testing.T) { +func TestJSONStoreCorruptedSessionBehaviors(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONSessionStore(baseDir) + store := NewJSONStore(baseDir) valid := &Session{ ID: "valid-session", @@ -125,7 +125,7 @@ func TestJSONSessionStoreCorruptedSessionBehaviors(t *testing.T) { t.Fatalf("Save valid session: %v", err) } - mustWriteRuntimeFile(t, filepath.Join(baseDir, sessionsDirName, "broken.json"), "{broken") + mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "broken.json"), "{broken") _, err := store.Load(context.Background(), "broken") if err == nil || !strings.Contains(err.Error(), "decode session broken") { @@ -141,7 +141,7 @@ func TestJSONSessionStoreCorruptedSessionBehaviors(t *testing.T) { } } -func TestJSONSessionStoreSaveInvalidBaseDir(t *testing.T) { +func TestJSONStoreSaveInvalidBaseDir(t *testing.T) { t.Parallel() tempDir := t.TempDir() @@ -150,7 +150,7 @@ func TestJSONSessionStoreSaveInvalidBaseDir(t *testing.T) { t.Fatalf("write base file: %v", err) } - store := NewJSONSessionStore(baseFile) + store := NewJSONStore(baseFile) err := store.Save(context.Background(), &Session{ ID: "session-x", Title: "Broken Save", @@ -162,7 +162,7 @@ func TestJSONSessionStoreSaveInvalidBaseDir(t *testing.T) { } } -func mustWriteRuntimeFile(t *testing.T, path string, content string) { +func mustWriteSessionFile(t *testing.T, path string, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) diff --git a/internal/tui/state.go b/internal/tui/state.go index 1f939dad..c65ecc63 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -10,7 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" ) type panel int @@ -32,7 +32,7 @@ const ( ) type UIState struct { - Sessions []agentruntime.SessionSummary + Sessions []agentsession.Summary ActiveSessionID string ActiveSessionTitle string InputText string @@ -142,7 +142,7 @@ func (d commandMenuDelegate) Render(w io.Writer, m list.Model, index int, item l } type sessionItem struct { - Summary agentruntime.SessionSummary + Summary agentsession.Summary Active bool } diff --git a/internal/tui/state/ui_state.go b/internal/tui/state/ui_state.go index ac6c4611..9fa071a3 100644 --- a/internal/tui/state/ui_state.go +++ b/internal/tui/state/ui_state.go @@ -1,6 +1,6 @@ package state -import agentruntime "neo-code/internal/runtime" +import agentsession "neo-code/internal/session" // Panel 定义 TUI 中可聚焦的主面板。 type Panel int @@ -24,7 +24,7 @@ const ( // UIState 保存顶层界面状态快照,仅作为数据容器使用。 type UIState struct { - Sessions []agentruntime.SessionSummary + Sessions []agentsession.Summary ActiveSessionID string ActiveSessionTitle string ActiveRunID string diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index a02958f7..c55e23ae 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -22,6 +22,7 @@ import ( providercatalog "neo-code/internal/provider/catalog" providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" "neo-code/internal/tools" ) @@ -29,15 +30,15 @@ type stubRuntime struct { runInputs []agentruntime.UserInput compactInputs []agentruntime.CompactInput events chan agentruntime.RuntimeEvent - sessions []agentruntime.SessionSummary - loads map[string]agentruntime.Session + sessions []agentsession.Summary + loads map[string]agentsession.Session runErr error compactErr error compactResult agentruntime.CompactResult listErr error loadErr error setWorkdirErr error - setResult *agentruntime.Session + setResult *agentsession.Session setCalls int resolveInputs []agentruntime.PermissionResolutionInput resolveErr error @@ -67,7 +68,7 @@ func (r *stubMarkdownRenderer) Render(content string, width int) (string, error) func newStubRuntime() *stubRuntime { return &stubRuntime{ events: make(chan agentruntime.RuntimeEvent, 16), - loads: map[string]agentruntime.Session{}, + loads: map[string]agentsession.Session{}, } } @@ -95,34 +96,34 @@ func (r *stubRuntime) CancelActiveRun() bool { return r.cancelResult } -func (r *stubRuntime) ListSessions(ctx context.Context) ([]agentruntime.SessionSummary, error) { +func (r *stubRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { if r.listErr != nil { return nil, r.listErr } - return append([]agentruntime.SessionSummary(nil), r.sessions...), nil + return append([]agentsession.Summary(nil), r.sessions...), nil } -func (r *stubRuntime) LoadSession(ctx context.Context, id string) (agentruntime.Session, error) { +func (r *stubRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { if r.loadErr != nil { - return agentruntime.Session{}, r.loadErr + return agentsession.Session{}, r.loadErr } if session, ok := r.loads[id]; ok { return session, nil } - return agentruntime.Session{}, nil + return agentsession.Session{}, nil } -func (r *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentruntime.Session, error) { +func (r *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { r.setCalls++ if r.setWorkdirErr != nil { - return agentruntime.Session{}, r.setWorkdirErr + return agentsession.Session{}, r.setWorkdirErr } if r.setResult != nil { return *r.setResult, nil } session, ok := r.loads[sessionID] if !ok { - session = agentruntime.Session{ID: sessionID} + session = agentsession.Session{ID: sessionID} } session.Workdir = strings.TrimSpace(workdir) r.loads[sessionID] = session @@ -251,7 +252,7 @@ func TestAppUpdateWorkspaceSlashCommands(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() sessionID := "session-workdir" - runtime.loads[sessionID] = agentruntime.Session{ID: sessionID, Workdir: t.TempDir()} + runtime.loads[sessionID] = agentsession.Session{ID: sessionID, Workdir: t.TempDir()} app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { @@ -328,7 +329,7 @@ func TestRunSessionWorkdirCommandBranches(t *testing.T) { t.Run("session workdir fallback uses current workdir when runtime returns empty", func(t *testing.T) { current := t.TempDir() runtime := newStubRuntime() - runtime.setResult = &agentruntime.Session{ID: "session-1", Workdir: ""} + runtime.setResult = &agentsession.Session{ID: "session-1", Workdir: ""} msg := runSessionWorkdirCommand(runtime, "session-1", current, "/cwd ./subdir")() result := msg.(sessionWorkdirResultMsg) if result.err != nil { @@ -343,7 +344,7 @@ func TestRunSessionWorkdirCommandBranches(t *testing.T) { current := t.TempDir() target := t.TempDir() runtime := newStubRuntime() - runtime.setResult = &agentruntime.Session{ID: "session-1", Workdir: target} + runtime.setResult = &agentsession.Session{ID: "session-1", Workdir: target} msg := runSessionWorkdirCommand(runtime, "session-1", current, "/cwd ./subdir")() result := msg.(sessionWorkdirResultMsg) if result.err != nil { @@ -776,7 +777,7 @@ func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { func TestAppHelpersAndRenderingSmoke(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() - now := agentruntime.Session{ + now := agentsession.Session{ ID: "session-1", Title: "Existing Session", Messages: []providertypes.Message{ @@ -784,7 +785,7 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { {Role: roleAssistant, Content: "hello"}, }, } - runtime.sessions = []agentruntime.SessionSummary{ + runtime.sessions = []agentsession.Summary{ {ID: now.ID, Title: now.Title, UpdatedAt: now.UpdatedAt}, } runtime.loads[now.ID] = now @@ -986,7 +987,7 @@ func TestTUIStandaloneHelpers(t *testing.T) { t.Fatalf("expected numeric helpers to work") } - sItem := sessionItem{Summary: agentruntime.SessionSummary{Title: "My Session"}} + sItem := sessionItem{Summary: agentsession.Summary{Title: "My Session"}} if sItem.FilterValue() != "my session" { t.Fatalf("unexpected session item filter value") } @@ -1208,8 +1209,8 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { { name: "session enter activates selected session", setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - runtime.sessions = []agentruntime.SessionSummary{{ID: "s1", Title: "One"}} - runtime.loads["s1"] = agentruntime.Session{ + runtime.sessions = []agentsession.Summary{{ID: "s1", Title: "One"}} + runtime.loads["s1"] = agentsession.Session{ ID: "s1", Title: "One", Messages: []providertypes.Message{{Role: roleAssistant, Content: "loaded"}}, From 85667966186e71ebc2a3deb01d5d9dba4ef1b4a7 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 7 Apr 2026 18:04:04 +0800 Subject: [PATCH 6/8] =?UTF-8?q?test:=E8=A1=A5=E5=85=85=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/session/id_test.go | 41 ++++++++++++++++++++++++++ internal/session/store_test.go | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 internal/session/id_test.go diff --git a/internal/session/id_test.go b/internal/session/id_test.go new file mode 100644 index 00000000..8ef1bb51 --- /dev/null +++ b/internal/session/id_test.go @@ -0,0 +1,41 @@ +package session + +import ( + "strings" + "testing" +) + +func TestNewIDFormatAndUniqueness(t *testing.T) { + t.Parallel() + + id1 := NewID("session") + id2 := NewID("session") + + if !strings.HasPrefix(id1, "session_") || !strings.HasPrefix(id2, "session_") { + t.Fatalf("expected prefix session_, got %q and %q", id1, id2) + } + + hex1 := strings.TrimPrefix(id1, "session_") + hex2 := strings.TrimPrefix(id2, "session_") + + if len(hex1) != 16 || len(hex2) != 16 { + t.Fatalf("expected 16 hex chars, got %d and %d", len(hex1), len(hex2)) + } + for _, ch := range hex1 + hex2 { + if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) { + t.Fatalf("expected lowercase hex, got %q in ids %q %q", ch, id1, id2) + } + } + if id1 == id2 { + t.Fatalf("expected different ids, got identical %q", id1) + } +} + +func TestNewIDAllowsEmptyPrefix(t *testing.T) { + t.Parallel() + + id := NewID("") + if len(id) != 17 || id[0] != '_' { + t.Fatalf("expected format _<16hex>, got %q", id) + } +} diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 985cc809..9474d761 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -162,6 +162,60 @@ func TestJSONStoreSaveInvalidBaseDir(t *testing.T) { } } +func TestNewUsesDefaultWorkdirAndEmptyMessages(t *testing.T) { + t.Parallel() + + session := New("hello title") + + if session.ID == "" { + t.Fatalf("expected non-empty id") + } + if !strings.HasPrefix(session.ID, "session_") { + t.Fatalf("expected id with session_ prefix, got %q", session.ID) + } + if session.Title != "hello title" { + t.Fatalf("expected title %q, got %q", "hello title", session.Title) + } + if session.Workdir != "" { + t.Fatalf("expected empty workdir, got %q", session.Workdir) + } + if len(session.Messages) != 0 { + t.Fatalf("expected empty messages, got %+v", session.Messages) + } + if session.CreatedAt.IsZero() || session.UpdatedAt.IsZero() { + t.Fatalf("expected non-zero timestamps, got created=%v updated=%v", session.CreatedAt, session.UpdatedAt) + } + if session.UpdatedAt.Before(session.CreatedAt) { + t.Fatalf("expected UpdatedAt >= CreatedAt, got created=%v updated=%v", session.CreatedAt, session.UpdatedAt) + } +} + +func TestNewWithWorkdirTrimAndTitleSanitize(t *testing.T) { + t.Parallel() + + tooLong := strings.Repeat("中", 45) // rune 长度 > 40 + workdir := " /tmp/workdir " + + session := NewWithWorkdir(tooLong, workdir) + + if session.Workdir != "/tmp/workdir" { + t.Fatalf("expected trimmed workdir %q, got %q", "/tmp/workdir", session.Workdir) + } + if got := len([]rune(session.Title)); got != 40 { + t.Fatalf("expected title rune length 40, got %d (title=%q)", got, session.Title) + } +} + +func TestNewWithWorkdirFallsBackDefaultTitle(t *testing.T) { + t.Parallel() + + session := NewWithWorkdir(" \n\t ", "") + + if session.Title != "New Session" { + t.Fatalf("expected default title %q, got %q", "New Session", session.Title) + } +} + func mustWriteSessionFile(t *testing.T, path string, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { From 360fb57b9f9f29ef77f7d08c7daae68ccc066af3 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 7 Apr 2026 20:22:16 +0800 Subject: [PATCH 7/8] =?UTF-8?q?test:=E6=B7=BB=E5=8A=A0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/runtime/runtime_test.go | 205 +++++++++++++++++++++++++++++++ internal/session/id_test.go | 12 ++ internal/session/store_test.go | 153 +++++++++++++++++++++++ 3 files changed, 370 insertions(+) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 242a59d3..7ff11961 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -2829,3 +2829,208 @@ func TestPermissionEventViewPayloadMapping(t *testing.T) { t.Fatalf("unexpected resolved payload: %+v", resolvedPayload) } } + +func TestStreamAccumulatorBuildMessageRejectsMissingToolName(t *testing.T) { + t.Parallel() + + acc := newStreamAccumulator() + acc.accumulateToolCallStart(0, "call-1", "") + acc.accumulateToolCallDelta(0, "call-1", "{}") + + _, err := acc.buildMessage() + if err == nil || !containsError(err, "without name") { + t.Fatalf("expected missing tool name error, got %v", err) + } +} + +func TestLoadSessionReturnsStoreError(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + service := NewWithFactory(manager, nil, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + + _, err := service.LoadSession(context.Background(), "missing") + if err == nil || !containsError(err, "not found") { + t.Fatalf("expected load error, got %v", err) + } +} + +func TestSetSessionWorkdirReturnsStoreError(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + service := NewWithFactory(manager, nil, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + + _, err := service.SetSessionWorkdir(context.Background(), "missing", t.TempDir()) + if err == nil || !containsError(err, "not found") { + t.Fatalf("expected load error from SetSessionWorkdir, got %v", err) + } +} + +func TestSetSessionWorkdirReturnsResolveError(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + defaultWorkdir := t.TempDir() + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = defaultWorkdir + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + + store := newMemoryStore() + session := agentsession.New("set bad workdir") + session.ID = "session-set-bad-workdir" + store.sessions[session.ID] = cloneSession(session) + + service := NewWithFactory(manager, nil, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + _, err := service.SetSessionWorkdir(context.Background(), session.ID, filepath.Join(defaultWorkdir, "missing-dir")) + if err == nil || !containsError(err, "resolve workdir") { + t.Fatalf("expected resolve workdir error, got %v", err) + } +} + +func TestServiceRunFailsWhenInitialUserMessageSaveFails(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + baseStore := newMemoryStore() + store := &failingStore{ + Store: baseStore, + saveErr: errors.New("save failed on first write"), + failOnSave: 1, + } + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + err := service.Run(context.Background(), UserInput{ + RunID: "run-initial-save-fail", + Content: "hello", + }) + if err == nil || !containsError(err, "save failed on first write") { + t.Fatalf("expected initial save error, got %v", err) + } +} + +func TestServiceRunFailsWhenAssistantSaveFails(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + baseStore := newMemoryStore() + store := &failingStore{ + Store: baseStore, + saveErr: errors.New("save failed on assistant"), + failOnSave: 2, + } + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + scripted := &scriptedProvider{ + streams: [][]provider.StreamEvent{ + {provider.NewTextDeltaStreamEvent("assistant reply")}, + }, + } + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) + err := service.Run(context.Background(), UserInput{ + RunID: "run-assistant-save-fail", + Content: "hello", + }) + if err == nil || !containsError(err, "save failed on assistant") { + t.Fatalf("expected assistant save error, got %v", err) + } +} + +func TestHandleProviderStreamEventErrorBranches(t *testing.T) { + t.Parallel() + + acc := newStreamAccumulator() + + err := handleProviderStreamEvent( + provider.StreamEvent{Type: provider.StreamEventToolCallStart}, + acc, + nil, + nil, + ) + if err == nil || !containsError(err, "tool_call_start event payload is nil") { + t.Fatalf("expected tool_call_start payload error, got %v", err) + } + + err = handleProviderStreamEvent( + provider.StreamEvent{Type: provider.StreamEventToolCallDelta}, + acc, + nil, + nil, + ) + if err == nil || !containsError(err, "tool_call_delta event payload is nil") { + t.Fatalf("expected tool_call_delta payload error, got %v", err) + } + + err = handleProviderStreamEvent( + provider.StreamEvent{Type: provider.StreamEventMessageDone}, + acc, + nil, + nil, + ) + if err == nil || !containsError(err, "message_done event payload is nil") { + t.Fatalf("expected message_done payload error, got %v", err) + } +} + +func TestEmitDropsWhenChannelFullAndContextCanceled(t *testing.T) { + t.Parallel() + + service := &Service{ + events: make(chan RuntimeEvent, 1), + } + service.events <- RuntimeEvent{Type: EventAgentChunk} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + done := make(chan struct{}) + go func() { + service.emit(ctx, EventError, "run-id", "session-id", "payload") + close(done) + }() + + select { + case <-done: + case <-time.After(1 * time.Second): + t.Fatal("emit should return when channel is full and context is canceled") + } +} + +func TestCallProviderWithRetryReturnsCombinedForwardError(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + store := newMemoryStore() + + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) error { + events <- provider.StreamEvent{Type: provider.StreamEventTextDelta} + return errors.New("provider chat failed") + }, + } + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) + + _, err := service.callProviderWithRetry( + context.Background(), + "run-forward-error", + "session-forward-error", + provider.ChatRequest{ + Model: "test-model", + SystemPrompt: "prompt", + Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}, + }, + ) + if err == nil || !containsError(err, "provider stream handling failed after provider error") { + t.Fatalf("expected combined forward/provider error, got %v", err) + } +} diff --git a/internal/session/id_test.go b/internal/session/id_test.go index 8ef1bb51..396779f8 100644 --- a/internal/session/id_test.go +++ b/internal/session/id_test.go @@ -39,3 +39,15 @@ func TestNewIDAllowsEmptyPrefix(t *testing.T) { t.Fatalf("expected format _<16hex>, got %q", id) } } + +func TestNewIDCompatibilityWrapper(t *testing.T) { + t.Parallel() + + id := newID("session") + if !strings.HasPrefix(id, "session_") { + t.Fatalf("expected compatibility wrapper to preserve prefix, got %q", id) + } + if len(strings.TrimPrefix(id, "session_")) != 16 { + t.Fatalf("expected compatibility wrapper to return 16 hex chars, got %q", id) + } +} diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 9474d761..96b2fcb2 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -2,6 +2,8 @@ package session import ( "context" + "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -216,6 +218,157 @@ func TestNewWithWorkdirFallsBackDefaultTitle(t *testing.T) { } } +func TestNewStoreReturnsJSONStore(t *testing.T) { + t.Parallel() + + store := NewStore(t.TempDir()) + if store == nil { + t.Fatalf("expected non-nil store") + } +} + +func TestJSONStoreListSummariesReadDirFailure(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + store := NewJSONStore(baseDir) + + // 把 sessions 目录位置占成普通文件,触发 ReadDir 失败路径。 + sessionsPath := filepath.Join(baseDir, sessionsDirName) + if err := os.WriteFile(sessionsPath, []byte("not-a-dir"), 0o644); err != nil { + t.Fatalf("write %s: %v", sessionsPath, err) + } + + _, err := store.ListSummaries(context.Background()) + if err == nil || !strings.Contains(err.Error(), "create sessions dir") { + t.Fatalf("expected create sessions dir error, got %v", err) + } +} + +func TestJSONStoreListSummariesContextCanceledDuringIteration(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + store := NewJSONStore(baseDir) + + for i := 0; i < 10; i++ { + s := &Session{ + ID: "session-iter-" + strings.Repeat("x", i+1), + Title: "iter", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := store.Save(context.Background(), s); err != nil { + t.Fatalf("save session %d: %v", i, err) + } + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := store.ListSummaries(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context canceled, got %v", err) + } +} + +func TestJSONStoreLoadDecodeErrorWithNonJSONPayload(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + store := NewJSONStore(baseDir) + + mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "decode-bad.json"), "{not-json") + + _, err := store.Load(context.Background(), "decode-bad") + if err == nil || !strings.Contains(err.Error(), "decode session decode-bad") { + t.Fatalf("expected decode session error, got %v", err) + } +} + +func TestJSONStoreListSummariesSkipsUnreadableAndMalformedEntries(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + store := NewJSONStore(baseDir) + + valid := &Session{ + ID: "valid-summary", + Title: "Valid", + CreatedAt: time.Now().Add(-time.Minute), + UpdatedAt: time.Now(), + } + if err := store.Save(context.Background(), valid); err != nil { + t.Fatalf("save valid session: %v", err) + } + + mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "malformed.json"), "{malformed") + mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "empty-id.json"), `{"id":" ","title":"x"}`) + + summaries, err := store.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() error: %v", err) + } + if len(summaries) != 1 || summaries[0].ID != valid.ID { + t.Fatalf("expected only valid summary, got %+v", summaries) + } +} + +func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + store := NewJSONStore(baseDir) + + session := &Session{ + ID: "persist-full-fields", + Title: "Persist Fields", + Provider: "openai", + Model: "gpt-4.1", + CreatedAt: time.Now().Add(-time.Hour), + UpdatedAt: time.Now(), + Messages: []provider.Message{ + {Role: provider.RoleUser, Content: "hello"}, + { + Role: provider.RoleAssistant, + Content: "calling tool", + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "webfetch", Arguments: `{"url":"https://example.com"}`}, + }, + }, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: "ok"}, + }, + } + + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("save session: %v", err) + } + + rawPath := filepath.Join(baseDir, sessionsDirName, session.ID+".json") + raw, err := os.ReadFile(rawPath) + if err != nil { + t.Fatalf("read raw file: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("decode raw json: %v", err) + } + + if decoded["provider"] != "openai" { + t.Fatalf("expected provider persisted, got %+v", decoded["provider"]) + } + if decoded["model"] != "gpt-4.1" { + t.Fatalf("expected model persisted, got %+v", decoded["model"]) + } + if _, ok := decoded["messages"]; !ok { + t.Fatalf("expected messages field persisted, got %+v", decoded) + } + if _, ok := decoded["workdir"]; ok { + t.Fatalf("expected workdir not persisted, got %+v", decoded) + } +} + func mustWriteSessionFile(t *testing.T, path string, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { From 48f7da0b6fd8afd8cf4da7455c46ae07292e6315 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Tue, 7 Apr 2026 22:11:52 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E4=B8=A2=E5=A4=B1?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=8C=E5=AE=8C=E5=85=A8=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/runtime/runtime.go | 43 +++++-------------------- internal/runtime/runtime_test.go | 4 +-- internal/runtime/workdir_branch_test.go | 23 ------------- internal/session/store.go | 2 +- internal/session/store_test.go | 13 ++++---- 5 files changed, 18 insertions(+), 67 deletions(-) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index a6352f22..f124ea9b 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -109,13 +109,6 @@ func (a *streamAccumulator) buildMessage() (providertypes.Message, error) { return message, nil } -var runtimeSessionWorkdirs = struct { - mu sync.RWMutex - data map[string]string -}{ - data: make(map[string]string), -} - type Runtime interface { Run(ctx context.Context, input UserInput) error Compact(ctx context.Context, input CompactInput) (CompactResult, error) @@ -383,7 +376,6 @@ func (s *Service) LoadSession(ctx context.Context, id string) (agentsession.Sess if err != nil { return agentsession.Session{}, err } - session.Workdir = s.sessionWorkdir(id, session.Workdir) return session, nil } @@ -397,7 +389,6 @@ func (s *Service) SetSessionWorkdir(ctx context.Context, sessionID string, workd if err != nil { return agentsession.Session{}, err } - session.Workdir = s.sessionWorkdir(sessionID, session.Workdir) cfg := s.configManager.Get() resolved, err := resolveWorkdirForSession(cfg.Workdir, session.Workdir, workdir) @@ -409,30 +400,11 @@ func (s *Service) SetSessionWorkdir(ctx context.Context, sessionID string, workd } session.Workdir = resolved - s.setSessionWorkdir(sessionID, resolved) - return session, nil -} - -func (s *Service) sessionWorkdir(sessionID string, fallback string) string { - key := s.sessionWorkdirKey(sessionID) - runtimeSessionWorkdirs.mu.RLock() - value, ok := runtimeSessionWorkdirs.data[key] - runtimeSessionWorkdirs.mu.RUnlock() - if ok { - return strings.TrimSpace(value) + session.UpdatedAt = time.Now() + if err := s.sessionStore.Save(ctx, &session); err != nil { + return agentsession.Session{}, err } - return strings.TrimSpace(fallback) -} - -func (s *Service) setSessionWorkdir(sessionID string, workdir string) { - key := s.sessionWorkdirKey(sessionID) - runtimeSessionWorkdirs.mu.Lock() - runtimeSessionWorkdirs.data[key] = strings.TrimSpace(workdir) - runtimeSessionWorkdirs.mu.Unlock() -} - -func (s *Service) sessionWorkdirKey(sessionID string) string { - return fmt.Sprintf("%p:%s", s, strings.TrimSpace(sessionID)) + return session, nil } func (s *Service) loadOrCreateSession( @@ -448,7 +420,6 @@ func (s *Service) loadOrCreateSession( return agentsession.Session{}, err } session := agentsession.NewWithWorkdir(title, sessionWorkdir) - s.setSessionWorkdir(session.ID, sessionWorkdir) if err := s.sessionStore.Save(ctx, &session); err != nil { return agentsession.Session{}, err } @@ -458,7 +429,6 @@ func (s *Service) loadOrCreateSession( if err != nil { return agentsession.Session{}, err } - session.Workdir = s.sessionWorkdir(sessionID, session.Workdir) if strings.TrimSpace(requestedWorkdir) == "" && strings.TrimSpace(session.Workdir) != "" { return session, nil } @@ -471,7 +441,10 @@ func (s *Service) loadOrCreateSession( return session, nil } session.Workdir = resolved - s.setSessionWorkdir(sessionID, resolved) + session.UpdatedAt = time.Now() + if err := s.sessionStore.Save(ctx, &session); err != nil { + return agentsession.Session{}, err + } return session, nil } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 4ed9c921..988f6201 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -2445,8 +2445,8 @@ func TestServiceSetSessionWorkdir(t *testing.T) { if err != nil { t.Fatalf("LoadSession() with new service error = %v", err) } - if strings.TrimSpace(reloaded.Workdir) != "" { - t.Fatalf("expected session workdir not to persist across process lifetime, got %q", reloaded.Workdir) + if reloaded.Workdir != target { + t.Fatalf("expected session workdir to persist across service lifetime, got %q", reloaded.Workdir) } _, err = service.SetSessionWorkdir(context.Background(), "", "sub") diff --git a/internal/runtime/workdir_branch_test.go b/internal/runtime/workdir_branch_test.go index 266fed5e..089a3cd0 100644 --- a/internal/runtime/workdir_branch_test.go +++ b/internal/runtime/workdir_branch_test.go @@ -10,29 +10,6 @@ import ( agentsession "neo-code/internal/session" ) -func TestSessionWorkdirKeyAndMemoryMap(t *testing.T) { - t.Parallel() - - serviceA := &Service{} - serviceB := &Service{} - - keyA := serviceA.sessionWorkdirKey("session-1") - keyB := serviceB.sessionWorkdirKey("session-1") - if keyA == keyB { - t.Fatalf("expected unique key per service instance, got %q", keyA) - } - - if got := serviceA.sessionWorkdir("session-1", "/fallback"); got != "/fallback" { - t.Fatalf("expected fallback workdir, got %q", got) - } - - target := t.TempDir() - serviceA.setSessionWorkdir("session-1", target) - if got := serviceA.sessionWorkdir("session-1", "/fallback"); got != target { - t.Fatalf("expected mapped workdir %q, got %q", target, got) - } -} - func TestResolveWorkdirForSessionAndNormalizeErrors(t *testing.T) { t.Parallel() diff --git a/internal/session/store.go b/internal/session/store.go index 824a8228..57639374 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -28,7 +28,7 @@ type Session struct { Model string `json:"model,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - Workdir string `json:"-"` + Workdir string `json:"workdir,omitempty"` Messages []providertypes.Message `json:"messages"` } diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 2334d244..6b412322 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -54,8 +54,8 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { if loaded.Title != older.Title { t.Fatalf("expected title %q, got %q", older.Title, loaded.Title) } - if loaded.Workdir != "" { - t.Fatalf("expected workdir to stay in-memory only, got %q", loaded.Workdir) + if loaded.Workdir != older.Workdir { + t.Fatalf("expected persisted workdir %q, got %q", older.Workdir, loaded.Workdir) } if len(loaded.Messages) != 2 || loaded.Messages[1].Content != "world" { t.Fatalf("unexpected loaded messages: %+v", loaded.Messages) @@ -66,8 +66,8 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { if err != nil { t.Fatalf("read saved session: %v", err) } - if strings.Contains(string(raw), "\"workdir\"") { - t.Fatalf("expected persisted session file to exclude workdir, got:\n%s", string(raw)) + if !strings.Contains(string(raw), "\"workdir\"") { + t.Fatalf("expected persisted session file to include workdir, got:\n%s", string(raw)) } mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "invalid.json"), "{invalid") @@ -325,6 +325,7 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { Title: "Persist Fields", Provider: "openai", Model: "gpt-4.1", + Workdir: "/tmp/persist-workdir", CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now(), Messages: []providertypes.Message{ @@ -364,8 +365,8 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { if _, ok := decoded["messages"]; !ok { t.Fatalf("expected messages field persisted, got %+v", decoded) } - if _, ok := decoded["workdir"]; ok { - t.Fatalf("expected workdir not persisted, got %+v", decoded) + if decoded["workdir"] != session.Workdir { + t.Fatalf("expected workdir persisted as %q, got %+v", session.Workdir, decoded["workdir"]) } }