diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index 2c87a234..247e5646 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -43,6 +43,10 @@ - runtime 只负责在结束点调度,不直接执行提取逻辑;实际 debounce、尾随执行与持久化去重由 `internal/memo` 内部处理。 - 调度时会绑定当次 provider/model 快照,后台任务不会重新读取全局当前配置,避免把历史会话消息发送到后续切换后的 provider。 - 自动提取失败只记日志,不额外发出 TUI 事件,也不影响主链路完成。 +- `memo` 的最近消息窗口会复用 `internal/context` 的只读投影规则,只保留 provider-safe 的消息序列。 +- assistant 含 `tool_calls` 时,只有在窗口内能同时保留对应 `tool` 响应时才会注入;缺响应、空内容或已被 micro compact 清空的 assistant/tool 片段会整组丢弃,保留项会先投影为模型可消费的结构化文本。 +- recent window 的总消息数有硬预算:`min(limit*2, 24)`;超过预算的整段 tool span 会被跳过,避免窗口体积失控。 +- 进入 `memo` 提取前,tool 文本会二次收敛为 `content_excerpt`,并按 `600` rune 上限截断。 补充约束: - 同一 turn 内的 provider retry 只重放冻结后的 turn 快照,不会重新读取配置。 @@ -90,6 +94,8 @@ - `internal/provider/streaming` 统一累积文本、tool call 增量和 `message_done` - runtime 将累积过程映射成 `RuntimeEvent` - TUI 使用 Bubble Tea `Cmd` 监听事件,并在处理完成后继续订阅 +- `provider.GenerateText` 只在上游 `Generate` 成功返回时,才把缺失 `message_done` 视为流式中断。 +- 如果 provider 在真正开始流式输出前直接返回 HTTP/ProviderError,则优先保留原始错误,不再额外包装成 `message_done` 缺失。 同一套流式累积逻辑同时复用于: - 普通 `Run()` 的 assistant 回复收敛 diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index 9059b7b8..c8429c62 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -1133,6 +1133,58 @@ func TestNewMemoExtractorAdapterSchedulesExtractorAndGenerates(t *testing.T) { } } +func TestNewMemoExtractorAdapterBuildsProviderSafeMemoWindow(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "token") + cfg := config.StaticDefaults().Clone() + cfg.SelectedProvider = config.OpenAIName + manager := config.NewManager(config.NewLoader("", &cfg)) + + providerStub := &stubMemoProvider{ + generate: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + if len(req.Messages) != 2 { + t.Fatalf("unexpected memo window: %+v", req.Messages) + } + for _, message := range req.Messages { + if len(message.ToolCalls) > 0 { + t.Fatalf("unexpected incomplete tool call span in memo window: %+v", req.Messages) + } + } + events <- providertypes.NewTextDeltaStreamEvent(`[]`) + events <- providertypes.NewMessageDoneStreamEvent("stop", nil) + return nil + }, + } + factory := &stubMemoProviderFactory{provider: providerStub} + scheduler := &stubMemoExtractorScheduler{} + extractor := newMemoExtractorAdapter(factory, manager, scheduler) + + inputMessages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "first"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call_1", Name: "filesystem_read_file", Arguments: `{}`}, + }, + }, + {Role: providertypes.RoleUser, Content: "second"}, + } + extractor.Schedule("session-1", inputMessages) + if !scheduler.called || scheduler.extractor == nil { + t.Fatalf("expected scheduler to receive extractor") + } + + entries, err := scheduler.extractor.Extract(context.Background(), inputMessages) + if err != nil { + t.Fatalf("extractor.Extract() error = %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected empty extracted entries, got %+v", entries) + } + if !factory.called { + t.Fatalf("expected provider factory Build to be called") + } +} + func TestNewMemoExtractorAdapterKeepsScheduledConfigSnapshot(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "openai-token") t.Setenv(config.QiniuDefaultAPIKeyEnv, "qiniu-token") diff --git a/internal/context/builder.go b/internal/context/builder.go index 108098c4..7f71caae 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -2,11 +2,9 @@ package context import ( "context" - "strings" providertypes "neo-code/internal/provider/types" agentsession "neo-code/internal/session" - "neo-code/internal/tools" ) // DefaultBuilder preserves the current runtime context-building behavior. @@ -86,7 +84,7 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu }, nil } -// applyReadTimeContextProjection 负责在 provider 请求前按开关应用只读上下文投影,避免改写原始会话消息。 +// applyReadTimeContextProjection 负责在 provider 读取路径上应用只读上下文投影,避免改写原始会话消息。 func applyReadTimeContextProjection( messages []providertypes.Message, taskState agentsession.TaskState, @@ -99,25 +97,5 @@ func applyReadTimeContextProjection( } else { projected = microCompactMessagesWithPolicies(messages, policies, options.MicroCompactRetainedToolSpans) } - return projectToolMessagesForModel(projected) -} - -// projectToolMessagesForModel 仅在 provider 读取路径上格式化 tool 消息,避免污染持久化会话内容。 -func projectToolMessagesForModel(messages []providertypes.Message) []providertypes.Message { - for i := range messages { - message := messages[i] - if message.Role != providertypes.RoleTool { - continue - } - if len(message.ToolMetadata) == 0 { - continue - } - content := strings.TrimSpace(message.Content) - if content == "" || content == microCompactClearedMessage { - continue - } - messages[i].Content = tools.FormatToolMessageForModel(message) - messages[i].ToolMetadata = nil - } - return messages + return ProjectToolMessagesForModel(projected) } diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 3603db30..98944de2 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -743,3 +743,30 @@ func TestNewBuilderWithMemo(t *testing.T) { } }) } + +func TestProjectToolMessagesForModelKeepsBuilderProjectionBehavior(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + { + Role: providertypes.RoleTool, + ToolCallID: "call-1", + Content: "tool output", + ToolMetadata: map[string]string{"tool_name": "filesystem_read_file", "path": "README.md"}, + }, + } + + projected := ProjectToolMessagesForModel(cloneContextMessages(messages)) + if len(projected) != 1 { + t.Fatalf("len(projected) = %d, want 1", len(projected)) + } + if !strings.Contains(projected[0].Content, "tool result") || !strings.Contains(projected[0].Content, "tool: filesystem_read_file") { + t.Fatalf("unexpected projected content: %q", projected[0].Content) + } + if projected[0].ToolMetadata != nil { + t.Fatalf("expected projected metadata to be cleared, got %#v", projected[0].ToolMetadata) + } + if messages[0].ToolMetadata == nil { + t.Fatal("expected source messages to remain unchanged") + } +} diff --git a/internal/context/projection.go b/internal/context/projection.go new file mode 100644 index 00000000..86c5a6dd --- /dev/null +++ b/internal/context/projection.go @@ -0,0 +1,235 @@ +package context + +import ( + "strings" + "unicode/utf8" + + providertypes "neo-code/internal/provider/types" + "neo-code/internal/tools" +) + +const ( + recentWindowAbsoluteMessageLimit = 24 + recentWindowToolContentCharLimit = 600 +) + +// ProjectToolMessagesForModel 原地投影 tool 消息,复用主链路对模型可见的只读格式化规则。 +func ProjectToolMessagesForModel(messages []providertypes.Message) []providertypes.Message { + for i := range messages { + message := messages[i] + if !isProjectableToolMessage(message) { + continue + } + messages[i].Content = tools.FormatToolMessageForModel(message) + messages[i].ToolMetadata = nil + } + return messages +} + +// BuildRecentMessagesForModel 从会话尾部构造 provider-safe 的最近消息窗口,避免保留非法 tool call 片段。 +func BuildRecentMessagesForModel(messages []providertypes.Message, limit int) []providertypes.Message { + if len(messages) == 0 || limit <= 0 { + return nil + } + + keep := make([]bool, len(messages)) + anchors := 0 + keptMessages := 0 + maxMessages := recentWindowMessageBudget(limit) + + markSpan := func(span []int) bool { + if len(span) == 0 || keptMessages+len(span) > maxMessages { + return false + } + for _, spanIndex := range span { + keep[spanIndex] = true + } + keptMessages += len(span) + anchors++ + return true + } + + for index := len(messages) - 1; index >= 0 && anchors < limit; index-- { + message := messages[index] + if message.Role == providertypes.RoleTool { + continue + } + + if message.Role == providertypes.RoleAssistant && len(message.ToolCalls) > 0 { + markSpan(matchedToolCallSpan(messages, index)) + continue + } + + if !markSpan([]int{index}) { + break + } + } + + selected := make([]providertypes.Message, 0, limit) + for index, message := range messages { + if !keep[index] { + continue + } + selected = append(selected, message) + } + if len(selected) == 0 { + return nil + } + + return sanitizeRecentWindowToolMessages(ProjectToolMessagesForModel(cloneContextMessages(selected))) +} + +// matchedToolCallSpan 返回 assistant tool call 与其完整 tool 响应组成的合法窗口下标集合。 +func matchedToolCallSpan(messages []providertypes.Message, assistantIndex int) []int { + if assistantIndex < 0 || assistantIndex >= len(messages) { + return nil + } + + message := messages[assistantIndex] + if message.Role != providertypes.RoleAssistant || len(message.ToolCalls) == 0 { + return nil + } + + required := make(map[string]struct{}, len(message.ToolCalls)) + for _, call := range message.ToolCalls { + callID := strings.TrimSpace(call.ID) + if callID == "" { + return nil + } + required[callID] = struct{}{} + } + if len(required) == 0 { + return nil + } + + matched := make(map[string]struct{}, len(required)) + toolIndexes := make([]int, 0, len(required)) + for index := assistantIndex + 1; index < len(messages); index++ { + toolMessage := messages[index] + if toolMessage.Role != providertypes.RoleTool { + break + } + if !isProjectableToolMessage(toolMessage) { + continue + } + + callID := strings.TrimSpace(toolMessage.ToolCallID) + if _, ok := required[callID]; !ok { + continue + } + if _, exists := matched[callID]; exists { + continue + } + matched[callID] = struct{}{} + toolIndexes = append(toolIndexes, index) + } + + if len(matched) != len(required) { + return nil + } + + span := make([]int, 0, len(toolIndexes)+1) + span = append(span, assistantIndex) + span = append(span, toolIndexes...) + return span +} + +// isProjectableToolMessage 判断 tool 消息是否满足“可注入且可投影”条件。 +func isProjectableToolMessage(message providertypes.Message) bool { + return isInjectableToolMessage(message) && len(message.ToolMetadata) > 0 +} + +// isInjectableToolMessage 判断 tool 消息是否仍适合作为模型可见上下文继续注入。 +func isInjectableToolMessage(message providertypes.Message) bool { + if message.Role != providertypes.RoleTool { + return false + } + content := strings.TrimSpace(message.Content) + return content != "" && content != microCompactClearedMessage +} + +// recentWindowMessageBudget 计算 recent window 可保留的消息总数硬上限,避免窗口体积失控。 +func recentWindowMessageBudget(limit int) int { + if limit <= 0 { + return 0 + } + budget := limit * 2 + if budget < limit { + budget = limit + } + if budget > recentWindowAbsoluteMessageLimit { + budget = recentWindowAbsoluteMessageLimit + } + return budget +} + +// sanitizeRecentWindowToolMessages 缩减 tool 消息内容,降低 memo 提取链路对原始工具输出的暴露面。 +func sanitizeRecentWindowToolMessages(messages []providertypes.Message) []providertypes.Message { + for index := range messages { + message := messages[index] + if message.Role != providertypes.RoleTool { + continue + } + messages[index].Content = sanitizeProjectedToolContent(message.Content) + } + return messages +} + +// sanitizeProjectedToolContent 将投影后的 tool content 限制为固定长度摘要,避免注入完整原始输出。 +func sanitizeProjectedToolContent(content string) string { + const contentMarker = "\ncontent:\n" + + index := strings.Index(content, contentMarker) + if index < 0 { + return sanitizeRawToolContent(content) + } + + prefix := strings.TrimRight(content[:index], "\n") + body := strings.TrimSpace(content[index+len(contentMarker):]) + if body == "" { + return prefix + } + + limited, truncated := truncateUTF8(body, recentWindowToolContentCharLimit) + lines := []string{prefix, "content_excerpt:", limited} + if truncated { + lines = append(lines, "[content truncated for memo extraction]") + } + return strings.Join(lines, "\n") +} + +// sanitizeRawToolContent 对未命中投影标记的 tool 文本做保底摘要化,避免透传完整原始输出。 +func sanitizeRawToolContent(content string) string { + body := strings.TrimSpace(content) + if body == "" { + return "" + } + limited, truncated := truncateUTF8(body, recentWindowToolContentCharLimit) + if !truncated { + return body + } + return strings.Join([]string{ + "content_excerpt:", + limited, + "[content truncated for memo extraction]", + }, "\n") +} + +// truncateUTF8 按 rune 数量截断字符串,返回截断后的文本及是否发生截断。 +func truncateUTF8(text string, maxRunes int) (string, bool) { + if maxRunes <= 0 || text == "" { + return "", text != "" + } + if utf8.RuneCountInString(text) <= maxRunes { + return text, false + } + + count := 0 + for index := range text { + if count == maxRunes { + return text[:index], true + } + count++ + } + return text, false +} diff --git a/internal/context/projection_test.go b/internal/context/projection_test.go new file mode 100644 index 00000000..84116afa --- /dev/null +++ b/internal/context/projection_test.go @@ -0,0 +1,322 @@ +package context + +import ( + "strings" + "testing" + + providertypes "neo-code/internal/provider/types" +) + +func TestProjectToolMessagesForModelSkipsMessagesThatCannotBeProjected(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "user"}, + { + Role: providertypes.RoleTool, + ToolCallID: "call-1", + Content: "tool output", + ToolMetadata: nil, + }, + { + Role: providertypes.RoleTool, + ToolCallID: "call-2", + Content: " ", + ToolMetadata: map[string]string{"tool_name": "bash"}, + }, + { + Role: providertypes.RoleTool, + ToolCallID: "call-3", + Content: microCompactClearedMessage, + ToolMetadata: map[string]string{"tool_name": "bash"}, + }, + { + Role: providertypes.RoleTool, + ToolCallID: "call-4", + Content: "result", + ToolMetadata: map[string]string{"tool_name": "filesystem_read_file", "path": "README.md"}, + }, + } + + projected := ProjectToolMessagesForModel(cloneContextMessages(messages)) + if projected[0].Content != "user" { + t.Fatalf("non-tool message should remain unchanged, got %+v", projected[0]) + } + if projected[1].Content != "tool output" || projected[1].ToolMetadata != nil { + t.Fatalf("tool without metadata-free projection should remain unchanged, got %+v", projected[1]) + } + if projected[2].Content != " " || projected[2].ToolMetadata == nil { + t.Fatalf("empty tool content should not be projected, got %+v", projected[2]) + } + if projected[3].Content != microCompactClearedMessage || projected[3].ToolMetadata == nil { + t.Fatalf("cleared tool content should not be projected, got %+v", projected[3]) + } + if !strings.Contains(projected[4].Content, "tool result") || projected[4].ToolMetadata != nil { + t.Fatalf("valid tool message should be projected, got %+v", projected[4]) + } +} + +func TestBuildRecentMessagesForModelBoundaries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + messages []providertypes.Message + limit int + }{ + { + name: "empty messages", + messages: nil, + limit: 10, + }, + { + name: "non-positive limit", + messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "x"}}, + limit: 0, + }, + { + name: "no keepable anchor", + messages: []providertypes.Message{ + {Role: providertypes.RoleTool, ToolCallID: "orphan", Content: "orphan"}, + }, + limit: 10, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := BuildRecentMessagesForModel(tt.messages, tt.limit); got != nil { + t.Fatalf("expected nil, got %+v", got) + } + }) + } +} + +func TestBuildRecentMessagesForModelKeepsOnlyRecentValidAnchors(t *testing.T) { + t.Parallel() + + original := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "old-user"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: `{"path":"README.md"}`}, + }, + }, + { + Role: providertypes.RoleTool, + ToolCallID: "call-1", + Content: "README body", + ToolMetadata: map[string]string{"tool_name": "filesystem_read_file", "path": "README.md"}, + }, + {Role: providertypes.RoleUser, Content: "latest-user"}, + } + + recent := BuildRecentMessagesForModel(original, 2) + if len(recent) != 3 { + t.Fatalf("len(recent) = %d, want 3", len(recent)) + } + if recent[0].Role != providertypes.RoleAssistant || len(recent[0].ToolCalls) != 1 { + t.Fatalf("expected valid tool span to remain, got %+v", recent[0]) + } + if recent[1].Role != providertypes.RoleTool || !strings.Contains(recent[1].Content, "tool result") { + t.Fatalf("expected tool message to be projected, got %+v", recent[1]) + } + if strings.Contains(recent[1].Content, "content:\nREADME body") { + t.Fatalf("expected tool payload to be minimized, got %+v", recent[1]) + } + if !strings.Contains(recent[1].Content, "content_excerpt:") { + t.Fatalf("expected tool payload excerpt marker, got %+v", recent[1]) + } + if recent[2].Content != "latest-user" { + t.Fatalf("expected latest user anchor to remain, got %+v", recent[2]) + } + + recent[1].Content = "changed" + if original[2].Content != "README body" { + t.Fatalf("expected original messages to remain unchanged, got %+v", original[2]) + } +} + +func TestBuildRecentMessagesForModelRespectsAbsoluteMessageBudget(t *testing.T) { + t.Parallel() + + longSpan := []providertypes.Message{ + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "bash", Arguments: `{}`}, + {ID: "call-2", Name: "bash", Arguments: `{}`}, + {ID: "call-3", Name: "bash", Arguments: `{}`}, + {ID: "call-4", Name: "bash", Arguments: `{}`}, + {ID: "call-5", Name: "bash", Arguments: `{}`}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "one", ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "two", ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Content: "three", ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-4", Content: "four", ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-5", Content: "five", ToolMetadata: map[string]string{"tool_name": "bash"}}, + } + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "old-user"}, + } + messages = append(messages, longSpan...) + messages = append(messages, providertypes.Message{Role: providertypes.RoleUser, Content: "latest-user"}) + + recent := BuildRecentMessagesForModel(messages, 3) + if len(recent) != 2 { + t.Fatalf("len(recent) = %d, want 2", len(recent)) + } + if recent[0].Content != "old-user" || recent[1].Content != "latest-user" { + t.Fatalf("expected oversized tool span to be skipped, got %+v", recent) + } +} + +func TestSanitizeProjectedToolContent(t *testing.T) { + t.Parallel() + + rawBody := strings.Repeat("甲", recentWindowToolContentCharLimit+10) + projected := "tool result\nstatus: ok\n\ncontent:\n" + rawBody + sanitized := sanitizeProjectedToolContent(projected) + if !strings.Contains(sanitized, "content_excerpt:") { + t.Fatalf("expected excerpt marker, got %q", sanitized) + } + if strings.Contains(sanitized, "content:\n") { + t.Fatalf("expected original content marker removed, got %q", sanitized) + } + if !strings.Contains(sanitized, "[content truncated for memo extraction]") { + t.Fatalf("expected truncation marker, got %q", sanitized) + } +} + +func TestMatchedToolCallSpanRejectsInvalidAssistantStates(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "user"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: " ", Name: "bash", Arguments: `{}`}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "tool output"}, + } + + if span := matchedToolCallSpan(messages, -1); span != nil { + t.Fatalf("expected nil span for invalid negative index, got %+v", span) + } + if span := matchedToolCallSpan(messages, len(messages)); span != nil { + t.Fatalf("expected nil span for invalid upper index, got %+v", span) + } + if span := matchedToolCallSpan(messages, 0); span != nil { + t.Fatalf("expected nil span for non-assistant message, got %+v", span) + } + if span := matchedToolCallSpan(messages, 1); span != nil { + t.Fatalf("expected nil span for blank tool call id, got %+v", span) + } +} + +func TestMatchedToolCallSpanRequiresInjectableResponsesAndSkipsDuplicates(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "bash", Arguments: `{}`}, + {ID: "call-2", Name: "filesystem_read_file", Arguments: `{}`}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "", ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "first result", ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "duplicate result", ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "ignored", Content: "ignored result", ToolMetadata: map[string]string{"tool_name": "bash"}}, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Content: "second result", ToolMetadata: map[string]string{"tool_name": "filesystem_read_file"}}, + {Role: providertypes.RoleUser, Content: "after"}, + } + + span := matchedToolCallSpan(messages, 0) + if len(span) != 3 { + t.Fatalf("len(span) = %d, want 3 (%+v)", len(span), span) + } + if span[0] != 0 || span[1] != 2 || span[2] != 5 { + t.Fatalf("unexpected span indexes %+v", span) + } +} + +func TestMatchedToolCallSpanRejectsResponsesWithoutProjectionMetadata(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "bash", Arguments: `{}`}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "raw result"}, + } + + if span := matchedToolCallSpan(messages, 0); span != nil { + t.Fatalf("expected nil span when tool metadata is missing, got %+v", span) + } +} + +func TestIsInjectableToolMessage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + message providertypes.Message + want bool + }{ + { + name: "non-tool", + message: providertypes.Message{Role: providertypes.RoleUser, Content: "user"}, + want: false, + }, + { + name: "empty", + message: providertypes.Message{Role: providertypes.RoleTool, Content: " "}, + want: false, + }, + { + name: "cleared", + message: providertypes.Message{Role: providertypes.RoleTool, Content: microCompactClearedMessage}, + want: false, + }, + { + name: "valid", + message: providertypes.Message{Role: providertypes.RoleTool, Content: "ok"}, + want: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := isInjectableToolMessage(tt.message); got != tt.want { + t.Fatalf("isInjectableToolMessage() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSanitizeProjectedToolContentFallsBackForRawPayload(t *testing.T) { + t.Parallel() + + raw := strings.Repeat("x", recentWindowToolContentCharLimit+10) + sanitized := sanitizeProjectedToolContent(raw) + if !strings.Contains(sanitized, "content_excerpt:") { + t.Fatalf("expected raw payload to be excerpted, got %q", sanitized) + } + if !strings.Contains(sanitized, "[content truncated for memo extraction]") { + t.Fatalf("expected truncation marker, got %q", sanitized) + } +} diff --git a/internal/memo/llm_extractor.go b/internal/memo/llm_extractor.go index 1aa10542..e46bd31f 100644 --- a/internal/memo/llm_extractor.go +++ b/internal/memo/llm_extractor.go @@ -8,6 +8,7 @@ import ( "strings" "time" + agentcontext "neo-code/internal/context" providertypes "neo-code/internal/provider/types" ) @@ -43,7 +44,7 @@ func (e *LLMExtractor) Extract(ctx context.Context, messages []providertypes.Mes return nil, errors.New("memo: text generator is nil") } - recent := recentMessagesForExtraction(messages, llmExtractorRecentMessageLimit) + recent := agentcontext.BuildRecentMessagesForModel(messages, llmExtractorRecentMessageLimit) if len(recent) == 0 || !containsUserMessage(recent) { return nil, nil } @@ -105,25 +106,6 @@ func buildExtractionPrompt(now time.Time) string { `, currentDate)) } -// recentMessagesForExtraction 截取最近的非 tool 消息,保留原始顺序。 -func recentMessagesForExtraction(messages []providertypes.Message, limit int) []providertypes.Message { - if len(messages) == 0 || limit <= 0 { - return nil - } - - filtered := make([]providertypes.Message, 0, len(messages)) - for _, message := range messages { - if message.Role == providertypes.RoleTool { - continue - } - filtered = append(filtered, cloneProviderMessage(message)) - } - if len(filtered) <= limit { - return filtered - } - return filtered[len(filtered)-limit:] -} - // containsUserMessage 检查待提取消息中是否包含用户输入。 func containsUserMessage(messages []providertypes.Message) bool { for _, message := range messages { @@ -223,18 +205,3 @@ func extractJSONArray(text string) (string, error) { return "", errors.New("memo: extraction response contains an incomplete JSON array") } - -// cloneProviderMessage 深拷贝消息结构,避免后台提取读取到运行时后续修改。 -func cloneProviderMessage(message providertypes.Message) providertypes.Message { - cloned := message - if len(message.ToolCalls) > 0 { - cloned.ToolCalls = append([]providertypes.ToolCall(nil), message.ToolCalls...) - } - if len(message.ToolMetadata) > 0 { - cloned.ToolMetadata = make(map[string]string, len(message.ToolMetadata)) - for key, value := range message.ToolMetadata { - cloned.ToolMetadata[key] = value - } - } - return cloned -} diff --git a/internal/memo/llm_extractor_test.go b/internal/memo/llm_extractor_test.go index bcac2a42..7d1befea 100644 --- a/internal/memo/llm_extractor_test.go +++ b/internal/memo/llm_extractor_test.go @@ -232,6 +232,102 @@ func TestLLMExtractorExtractUsesRecentNonToolMessages(t *testing.T) { } } +func TestLLMExtractorExtractDropsIncompleteToolCallSpan(t *testing.T) { + generator := &stubTextGenerator{response: `[]`} + extractor := NewLLMExtractor(generator) + + _, err := extractor.Extract(context.Background(), []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "first"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call_1", Name: "filesystem_read_file", Arguments: `{}`}, + }, + }, + {Role: providertypes.RoleUser, Content: "second"}, + }) + if err != nil { + t.Fatalf("Extract() error = %v", err) + } + if len(generator.messages) != 2 { + t.Fatalf("len(generator.messages) = %d, want 2", len(generator.messages)) + } + for _, message := range generator.messages { + if len(message.ToolCalls) > 0 { + t.Fatalf("unexpected tool call message in extraction window: %#v", message) + } + } +} + +func TestLLMExtractorExtractKeepsProjectedToolCallSpan(t *testing.T) { + generator := &stubTextGenerator{response: `[]`} + extractor := NewLLMExtractor(generator) + + _, err := extractor.Extract(context.Background(), []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "remember this"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call_1", Name: "filesystem_read_file", Arguments: `{"path":"README.md"}`}, + }, + }, + { + Role: providertypes.RoleTool, + ToolCallID: "call_1", + Content: "README body", + ToolMetadata: map[string]string{"tool_name": "filesystem_read_file", "path": "README.md"}, + }, + }) + if err != nil { + t.Fatalf("Extract() error = %v", err) + } + if len(generator.messages) != 3 { + t.Fatalf("len(generator.messages) = %d, want 3", len(generator.messages)) + } + if generator.messages[1].Role != providertypes.RoleAssistant || len(generator.messages[1].ToolCalls) != 1 { + t.Fatalf("expected assistant tool call span to be preserved, got %#v", generator.messages[1]) + } + toolMessage := generator.messages[2] + if toolMessage.Role != providertypes.RoleTool { + t.Fatalf("expected projected tool message, got %#v", toolMessage) + } + if !strings.Contains(toolMessage.Content, "tool result") || !strings.Contains(toolMessage.Content, "tool: filesystem_read_file") { + t.Fatalf("expected projected tool text, got %q", toolMessage.Content) + } + if toolMessage.ToolMetadata != nil { + t.Fatalf("expected projected tool metadata to be cleared, got %#v", toolMessage.ToolMetadata) + } +} + +func TestLLMExtractorExtractSkipsOrphanAndClearedToolMessages(t *testing.T) { + generator := &stubTextGenerator{response: `[]`} + extractor := NewLLMExtractor(generator) + + _, err := extractor.Extract(context.Background(), []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "alpha"}, + {Role: providertypes.RoleTool, ToolCallID: "orphan", Content: "orphan result"}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call_1", Name: "bash", Arguments: `{}`}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call_1", Content: "[Old tool result content cleared]"}, + {Role: providertypes.RoleUser, Content: "beta"}, + }) + if err != nil { + t.Fatalf("Extract() error = %v", err) + } + if len(generator.messages) != 2 { + t.Fatalf("len(generator.messages) = %d, want 2", len(generator.messages)) + } + for _, message := range generator.messages { + if message.Role == providertypes.RoleTool || len(message.ToolCalls) > 0 { + t.Fatalf("unexpected tool-related message in extraction window: %#v", message) + } + } +} + func TestLLMExtractorExtractNilGenerator(t *testing.T) { var extractor *LLMExtractor _, err := extractor.Extract(context.Background(), []providertypes.Message{ @@ -268,26 +364,3 @@ func TestExtractJSONArrayErrors(t *testing.T) { t.Fatalf("expected incomplete array error, got %v", err) } } - -func TestCloneProviderMessageDeepCopy(t *testing.T) { - original := providertypes.Message{ - Role: providertypes.RoleAssistant, - Content: "hello", - ToolCalls: []providertypes.ToolCall{ - {ID: "1", Name: "a", Arguments: "{}"}, - }, - ToolMetadata: map[string]string{"k": "v"}, - } - - cloned := cloneProviderMessage(original) - original.ToolCalls[0].Name = "changed" - original.ToolMetadata["k"] = "changed" - if cloned.ToolCalls[0].Name != "a" || cloned.ToolMetadata["k"] != "v" { - t.Fatalf("cloneProviderMessage should deep copy nested values, got %#v", cloned) - } - - plain := cloneProviderMessage(providertypes.Message{Role: providertypes.RoleUser, Content: "x"}) - if len(plain.ToolCalls) != 0 || plain.ToolMetadata != nil { - t.Fatalf("plain clone should not create nested values, got %#v", plain) - } -} diff --git a/internal/memo/message_clone.go b/internal/memo/message_clone.go new file mode 100644 index 00000000..5c5061c9 --- /dev/null +++ b/internal/memo/message_clone.go @@ -0,0 +1,18 @@ +package memo + +import providertypes "neo-code/internal/provider/types" + +// cloneProviderMessage 深拷贝消息结构,避免后台流程读取到运行时后续修改。 +func cloneProviderMessage(message providertypes.Message) providertypes.Message { + cloned := message + if len(message.ToolCalls) > 0 { + cloned.ToolCalls = append([]providertypes.ToolCall(nil), message.ToolCalls...) + } + if len(message.ToolMetadata) > 0 { + cloned.ToolMetadata = make(map[string]string, len(message.ToolMetadata)) + for key, value := range message.ToolMetadata { + cloned.ToolMetadata[key] = value + } + } + return cloned +} diff --git a/internal/memo/message_clone_test.go b/internal/memo/message_clone_test.go new file mode 100644 index 00000000..8b099c2e --- /dev/null +++ b/internal/memo/message_clone_test.go @@ -0,0 +1,45 @@ +package memo + +import ( + "testing" + + providertypes "neo-code/internal/provider/types" +) + +func TestCloneProviderMessageDeepCopy(t *testing.T) { + t.Parallel() + + original := providertypes.Message{ + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "bash", Arguments: `{"command":"pwd"}`}, + }, + ToolMetadata: map[string]string{"tool_name": "bash"}, + } + + cloned := cloneProviderMessage(original) + cloned.ToolCalls[0].Arguments = `{"command":"ls"}` + cloned.ToolMetadata["tool_name"] = "filesystem" + + if original.ToolCalls[0].Arguments != `{"command":"pwd"}` { + t.Fatalf("expected tool calls to be deep copied, got %+v", original.ToolCalls) + } + if original.ToolMetadata["tool_name"] != "bash" { + t.Fatalf("expected tool metadata to be deep copied, got %+v", original.ToolMetadata) + } +} + +func TestCloneProviderMessageHandlesEmptyCollections(t *testing.T) { + t.Parallel() + + cloned := cloneProviderMessage(providertypes.Message{Role: providertypes.RoleUser, Content: "hi"}) + if cloned.Role != providertypes.RoleUser || cloned.Content != "hi" { + t.Fatalf("unexpected cloned message %+v", cloned) + } + if cloned.ToolCalls != nil { + t.Fatalf("expected nil tool calls, got %+v", cloned.ToolCalls) + } + if cloned.ToolMetadata != nil { + t.Fatalf("expected nil tool metadata, got %+v", cloned.ToolMetadata) + } +} diff --git a/internal/provider/generate.go b/internal/provider/generate.go index c1d2b544..2c426564 100644 --- a/internal/provider/generate.go +++ b/internal/provider/generate.go @@ -2,12 +2,15 @@ package provider import ( "context" + "errors" "fmt" "strings" providertypes "neo-code/internal/provider/types" ) +var errGenerateTextMissingMessageDone = errors.New("provider stream ended without message_done event") + // GenerateText 聚合并消费流式事件,直接返回完整字符串。消灭上层流处理样板代码。 func GenerateText(ctx context.Context, p Provider, req providertypes.GenerateRequest) (string, error) { events := make(chan providertypes.StreamEvent, 32) @@ -43,7 +46,7 @@ func GenerateText(ctx context.Context, p Provider, req providertypes.GenerateReq } } if streamErr == nil && !messageDone { - streamErr = fmt.Errorf("provider stream ended without message_done event") + streamErr = errGenerateTextMissingMessageDone } done <- streamErr }() @@ -51,14 +54,15 @@ func GenerateText(ctx context.Context, p Provider, req providertypes.GenerateReq err := p.Generate(ctx, req, events) close(events) - if streamErr := <-done; streamErr != nil { - if err != nil { - return "", fmt.Errorf("generate failed: %v: %w", streamErr, err) + streamErr := <-done + if err != nil { + if streamErr == nil || errors.Is(streamErr, errGenerateTextMissingMessageDone) { + return "", err } - return "", streamErr + return "", fmt.Errorf("generate failed: %v: %w", streamErr, err) } - if err != nil { - return "", err + if streamErr != nil { + return "", streamErr } return builder.String(), nil } diff --git a/internal/provider/generate_test.go b/internal/provider/generate_test.go index 2636aaa5..89321e80 100644 --- a/internal/provider/generate_test.go +++ b/internal/provider/generate_test.go @@ -70,6 +70,41 @@ func TestGenerateTextProviderError(t *testing.T) { } } +func TestGenerateTextPrefersDirectProviderErrorBeforeStreaming(t *testing.T) { + providerErr := provider.NewProviderErrorFromStatus(400, "invalid_request_error") + providerStub := &stubTextGenProvider{ + generate: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + return providerErr + }, + } + + _, err := provider.GenerateText(context.Background(), providerStub, providertypes.GenerateRequest{}) + if !errors.Is(err, providerErr) { + t.Fatalf("expected provider error, got %v", err) + } + if strings.Contains(err.Error(), "message_done") { + t.Fatalf("unexpected message_done wrapper: %v", err) + } +} + +func TestGenerateTextPrefersProviderErrorAfterPartialStream(t *testing.T) { + providerErr := provider.NewProviderErrorFromStatus(500, "upstream failed") + providerStub := &stubTextGenProvider{ + generate: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + events <- providertypes.NewTextDeltaStreamEvent("partial") + return providerErr + }, + } + + _, err := provider.GenerateText(context.Background(), providerStub, providertypes.GenerateRequest{}) + if !errors.Is(err, providerErr) { + t.Fatalf("expected provider error, got %v", err) + } + if strings.Contains(err.Error(), "message_done") { + t.Fatalf("unexpected message_done wrapper: %v", err) + } +} + func TestGenerateTextReturnsEmptyTextWhenProviderErrorsAfterStreaming(t *testing.T) { providerStub := &stubTextGenProvider{ generate: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { @@ -146,3 +181,21 @@ func TestGenerateTextRejectsMessageDoneWithNilPayload(t *testing.T) { t.Fatalf("GenerateText() error = %v", err) } } + +func TestGenerateTextCombinesProviderAndStreamErrors(t *testing.T) { + providerErr := errors.New("provider error") + providerStub := &stubTextGenProvider{ + generate: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + events <- providertypes.StreamEvent{Type: "unexpected"} + return providerErr + }, + } + + _, err := provider.GenerateText(context.Background(), providerStub, providertypes.GenerateRequest{}) + if !errors.Is(err, providerErr) { + t.Fatalf("expected wrapped provider error, got %v", err) + } + if !strings.Contains(err.Error(), "unexpected provider stream event") { + t.Fatalf("expected stream error context to be preserved, got %v", err) + } +}