From e3c0e382dbf5cd0612e60d7a042a05c5add26f4c Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sat, 4 Apr 2026 22:51:47 +0800 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=20context=20?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E4=B8=8E=20compact=20=E5=86=85=E9=83=A8?= =?UTF-8?q?=E8=81=8C=E8=B4=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/builder.go | 33 +- internal/context/compact/helpers.go | 37 ++ internal/context/compact/planner.go | 178 ++++++ internal/context/compact/planner_test.go | 75 +++ internal/context/compact/runner.go | 513 ++---------------- internal/context/compact/summary_validator.go | 113 ++++ .../context/compact/summary_validator_test.go | 41 ++ internal/context/compact/transcript_store.go | 157 ++++++ .../context/compact/transcript_store_test.go | 60 ++ internal/context/prompt.go | 14 +- internal/context/sources.go | 50 ++ internal/context/sources_test.go | 67 +++ internal/context/trim_policy.go | 16 + 13 files changed, 869 insertions(+), 485 deletions(-) create mode 100644 internal/context/compact/helpers.go create mode 100644 internal/context/compact/planner.go create mode 100644 internal/context/compact/planner_test.go create mode 100644 internal/context/compact/summary_validator.go create mode 100644 internal/context/compact/summary_validator_test.go create mode 100644 internal/context/compact/transcript_store.go create mode 100644 internal/context/compact/transcript_store_test.go create mode 100644 internal/context/sources.go create mode 100644 internal/context/sources_test.go create mode 100644 internal/context/trim_policy.go diff --git a/internal/context/builder.go b/internal/context/builder.go index abd9523a..1c7f8e30 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -4,13 +4,20 @@ import "context" // DefaultBuilder preserves the current runtime context-building behavior. type DefaultBuilder struct { - gitRunner gitCommandRunner + promptSources []promptSectionSource + trimPolicy messageTrimPolicy } // NewBuilder returns the default context builder implementation. func NewBuilder() Builder { + systemSource := systemStateSource{gitRunner: runGitCommand} return &DefaultBuilder{ - gitRunner: runGitCommand, + promptSources: []promptSectionSource{ + corePromptSource{}, + projectRulesSource{}, + systemSource, + }, + trimPolicy: spanMessageTrimPolicy{}, } } @@ -20,22 +27,22 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu return BuildResult{}, err } - rules, err := loadProjectRules(ctx, input.Metadata.Workdir) - if err != nil { - return BuildResult{}, err + sections := make([]promptSection, 0, len(b.promptSources)+1) + for _, source := range b.promptSources { + sourceSections, err := source.Sections(ctx, input) + if err != nil { + return BuildResult{}, err + } + sections = append(sections, sourceSections...) } - systemState, err := collectSystemState(ctx, input.Metadata, b.gitRunner) - if err != nil { - return BuildResult{}, err + trimPolicy := b.trimPolicy + if trimPolicy == nil { + trimPolicy = spanMessageTrimPolicy{} } - sections := append([]promptSection{}, defaultSystemPromptSections()...) - sections = append(sections, renderProjectRulesSection(rules)) - sections = append(sections, renderSystemStateSection(systemState)) - return BuildResult{ SystemPrompt: composeSystemPrompt(sections...), - Messages: trimMessages(input.Messages), + Messages: trimPolicy.Trim(input.Messages), }, nil } diff --git a/internal/context/compact/helpers.go b/internal/context/compact/helpers.go new file mode 100644 index 00000000..edbd8420 --- /dev/null +++ b/internal/context/compact/helpers.go @@ -0,0 +1,37 @@ +package compact + +import ( + "unicode/utf8" + + "neo-code/internal/provider" +) + +// cloneMessages 深拷贝消息切片,避免后续规划或摘要阶段共享底层数据。 +func cloneMessages(messages []provider.Message) []provider.Message { + if len(messages) == 0 { + return nil + } + out := make([]provider.Message, 0, len(messages)) + for _, message := range messages { + next := message + next.ToolCalls = append([]provider.ToolCall(nil), message.ToolCalls...) + out = append(out, next) + } + return out +} + +// countMessageChars 以 rune 数量统计消息体积,用于 compact 前后指标计算。 +func countMessageChars(messages []provider.Message) int { + total := 0 + for _, message := range messages { + total += utf8.RuneCountInString(message.Role) + total += utf8.RuneCountInString(message.Content) + total += utf8.RuneCountInString(message.ToolCallID) + for _, call := range message.ToolCalls { + total += utf8.RuneCountInString(call.ID) + total += utf8.RuneCountInString(call.Name) + total += utf8.RuneCountInString(call.Arguments) + } + } + return total +} diff --git a/internal/context/compact/planner.go b/internal/context/compact/planner.go new file mode 100644 index 00000000..83da831d --- /dev/null +++ b/internal/context/compact/planner.go @@ -0,0 +1,178 @@ +package compact + +import ( + "fmt" + "strings" + + "neo-code/internal/config" + "neo-code/internal/provider" +) + +// compactionPlan 描述一次 compact 在摘要生成前的归档与保留结果。 +type compactionPlan struct { + Archived []provider.Message + Retained []provider.Message + ArchivedMessageCount int + Applied bool +} + +type atomicBlock struct { + start int + end int + messageCount int + protected bool +} + +// compactionPlanner 只负责选择策略并规划 archived/retained 消息边界。 +type compactionPlanner struct{} + +// Plan 根据 mode 与配置返回摘要前的裁剪规划结果。 +func (compactionPlanner) Plan(mode Mode, messages []provider.Message, cfg config.CompactConfig) (compactionPlan, error) { + if mode == ModeReactive { + return planKeepRecent(messages, cfg.ManualKeepRecentMessages), nil + } + + switch strings.ToLower(strings.TrimSpace(cfg.ManualStrategy)) { + case config.CompactManualStrategyKeepRecent: + return planKeepRecent(messages, cfg.ManualKeepRecentMessages), nil + case config.CompactManualStrategyFullReplace: + return planFullReplace(messages), nil + default: + return compactionPlan{}, fmt.Errorf("compact: manual strategy %q is not supported", cfg.ManualStrategy) + } +} + +// planKeepRecent 计算 keep_recent 策略下需要摘要与保留的消息集合。 +func planKeepRecent(messages []provider.Message, keepMessages int) compactionPlan { + blocks := collectAtomicBlocks(messages) + retainedStart := retainedStartForKeepRecent(blocks, keepMessages) + if retainedStart <= 0 { + return compactionPlan{ + Retained: cloneMessages(messages), + Applied: false, + } + } + + archived, retained := splitMessagesAt(messages, retainedStart) + return compactionPlan{ + Archived: archived, + Retained: retained, + ArchivedMessageCount: len(archived), + Applied: len(archived) > 0, + } +} + +// planFullReplace 计算 full_replace 策略下需要摘要与保留的消息集合。 +func planFullReplace(messages []provider.Message) compactionPlan { + if len(messages) == 0 { + return compactionPlan{} + } + + blocks := collectAtomicBlocks(messages) + retainedStart, hasProtectedTail := protectedTailStart(blocks) + if !hasProtectedTail { + retainedStart = len(messages) + } + + archived, retained := splitMessagesAt(messages, retainedStart) + return compactionPlan{ + Archived: archived, + Retained: retained, + ArchivedMessageCount: len(archived), + Applied: len(archived) > 0, + } +} + +// collectAtomicBlocks 按不可拆分的消息块分组,确保 tool call 与结果始终一起保留。 +func collectAtomicBlocks(messages []provider.Message) []atomicBlock { + blocks := make([]atomicBlock, 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 { + end++ + } + } + blocks = append(blocks, atomicBlock{ + start: start, + end: end, + messageCount: end - start, + protected: isProtectedThoughtBlock(messages[start:end]), + }) + i = end + } + if lastUserIndex := lastExplicitUserMessageIndex(messages); lastUserIndex >= 0 { + markBlockProtected(blocks, lastUserIndex) + } + return blocks +} + +// isProtectedThoughtBlock 为未来结构化 thought block 预留保护入口,当前默认不命中。 +func isProtectedThoughtBlock(messages []provider.Message) bool { + return false +} + +// lastExplicitUserMessageIndex 返回最后一条非空用户消息的位置,用于保护最近明确指令。 +func lastExplicitUserMessageIndex(messages []provider.Message) int { + for index := len(messages) - 1; index >= 0; index-- { + if messages[index].Role == provider.RoleUser && strings.TrimSpace(messages[index].Content) != "" { + return index + } + } + return -1 +} + +// markBlockProtected 将包含目标消息的原子块标记为受保护块。 +func markBlockProtected(blocks []atomicBlock, messageIndex int) { + for index := range blocks { + if messageIndex >= blocks[index].start && messageIndex < blocks[index].end { + blocks[index].protected = true + return + } + } +} + +// retainedStartForKeepRecent 计算 keep_recent 策略下保留尾部的起点。 +func retainedStartForKeepRecent(blocks []atomicBlock, keepMessages int) int { + if len(blocks) == 0 { + return 0 + } + + retainedStart := blocks[0].start + retainedMessages := 0 + for index := len(blocks) - 1; index >= 0; index-- { + retainedMessages += blocks[index].messageCount + retainedStart = blocks[index].start + if retainedMessages >= keepMessages { + break + } + } + + protectedStart, ok := protectedTailStart(blocks) + if ok && protectedStart < retainedStart { + retainedStart = protectedStart + } + return retainedStart +} + +// protectedTailStart 返回必须原样保留的受保护尾部起点。 +func protectedTailStart(blocks []atomicBlock) (int, bool) { + for index := range blocks { + if blocks[index].protected { + return blocks[index].start, true + } + } + return 0, false +} + +// splitMessagesAt 按 retained 起点切分 archived 与 retained,并返回深拷贝结果。 +func splitMessagesAt(messages []provider.Message, retainedStart int) ([]provider.Message, []provider.Message) { + if retainedStart <= 0 { + return nil, cloneMessages(messages) + } + if retainedStart >= len(messages) { + return cloneMessages(messages), nil + } + return cloneMessages(messages[:retainedStart]), cloneMessages(messages[retainedStart:]) +} diff --git a/internal/context/compact/planner_test.go b/internal/context/compact/planner_test.go new file mode 100644 index 00000000..3a2a10e9 --- /dev/null +++ b/internal/context/compact/planner_test.go @@ -0,0 +1,75 @@ +package compact + +import ( + "testing" + + "neo-code/internal/config" + "neo-code/internal/provider" +) + +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"}, + }, config.CompactConfig{ + ManualStrategy: config.CompactManualStrategyKeepRecent, + ManualKeepRecentMessages: 3, + }) + if err != nil { + t.Fatalf("Plan() error = %v", err) + } + if !plan.Applied { + t.Fatalf("expected keep_recent plan applied") + } + 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 { + t.Fatalf("expected retained tool block start, got %+v", plan.Retained[0]) + } + if plan.Retained[1].Role != provider.RoleTool { + t.Fatalf("expected retained tool result, got %+v", plan.Retained[1]) + } +} + +func TestCompactionPlannerFullReplaceProtectsLatestExplicitUserInstruction(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.RoleUser, Content: "latest instruction"}, + {Role: provider.RoleAssistant, Content: "latest answer"}, + }, config.CompactConfig{ + ManualStrategy: config.CompactManualStrategyFullReplace, + }) + if err != nil { + t.Fatalf("Plan() error = %v", err) + } + if !plan.Applied { + t.Fatalf("expected full_replace plan applied") + } + 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" { + t.Fatalf("expected latest explicit user instruction to stay retained, got %+v", plan.Retained) + } +} + +func TestCompactionPlannerRejectsUnsupportedStrategy(t *testing.T) { + t.Parallel() + + _, err := (compactionPlanner{}).Plan(ModeManual, nil, config.CompactConfig{ManualStrategy: "unknown"}) + if err == nil { + t.Fatalf("expected unsupported strategy error") + } +} diff --git a/internal/context/compact/runner.go b/internal/context/compact/runner.go index 710a95be..1896c03b 100644 --- a/internal/context/compact/runner.go +++ b/internal/context/compact/runner.go @@ -2,22 +2,13 @@ package compact import ( "context" - cryptorand "crypto/rand" - "crypto/sha1" - "encoding/hex" - "encoding/json" "errors" "fmt" "os" - "path/filepath" - "regexp" - goruntime "runtime" "strings" "time" - "unicode/utf8" "neo-code/internal/config" - "neo-code/internal/context/internalcompact" "neo-code/internal/provider" ) @@ -38,15 +29,6 @@ const ( ErrorModeNone ErrorMode = "none" ) -const ( - neocodeDataDirName = ".neocode" - compactProjectsDirName = "projects" - compactTranscriptsDirName = ".transcripts" - transcriptFallbackSessionID = "draft" - transcriptFileExtension = ".jsonl" - transcriptTemporarySuffix = ".tmp" -) - // Input is a single compact execution request. type Input struct { Mode Mode @@ -95,27 +77,31 @@ type Runner interface { // Service is the default compact implementation. type Service struct { - generator SummaryGenerator - now func() time.Time - randomToken func() (string, error) - userHomeDir func() (string, error) - mkdirAll func(path string, perm os.FileMode) error - writeFile func(name string, data []byte, perm os.FileMode) error - rename func(oldPath, newPath string) error - remove func(path string) error + generator SummaryGenerator + now func() time.Time + randomToken func() (string, error) + userHomeDir func() (string, error) + mkdirAll func(path string, perm os.FileMode) error + writeFile func(name string, data []byte, perm os.FileMode) error + rename func(oldPath, newPath string) error + remove func(path string) error + planner compactionPlanner + summaryVerifier compactSummaryValidator } // NewRunner returns the default compact runner. func NewRunner(generator SummaryGenerator) *Service { return &Service{ - generator: generator, - now: time.Now, - randomToken: randomTranscriptToken, - userHomeDir: os.UserHomeDir, - mkdirAll: os.MkdirAll, - writeFile: os.WriteFile, - rename: os.Rename, - remove: os.Remove, + generator: generator, + now: time.Now, + randomToken: randomTranscriptToken, + userHomeDir: os.UserHomeDir, + mkdirAll: os.MkdirAll, + writeFile: os.WriteFile, + rename: os.Rename, + remove: os.Remove, + planner: compactionPlanner{}, + summaryVerifier: compactSummaryValidator{}, } } @@ -133,6 +119,7 @@ func (s *Service) Run(ctx context.Context, input Input) (Result, error) { if input.Mode == ModeReactive { cfg.ManualStrategy = config.CompactManualStrategyKeepRecent } + messages := cloneMessages(input.Messages) beforeChars := countMessageChars(messages) base := Result{ @@ -147,22 +134,35 @@ func (s *Service) Run(ctx context.Context, input Input) (Result, error) { }, } - transcriptID, transcriptPath, err := s.saveTranscript(messages, strings.TrimSpace(input.SessionID), strings.TrimSpace(input.Workdir)) + store := s.transcriptStore() + transcriptID, transcriptPath, err := store.Save(messages, strings.TrimSpace(input.SessionID), strings.TrimSpace(input.Workdir)) if err != nil { return Result{}, err } base.TranscriptID = transcriptID base.TranscriptPath = transcriptPath - next, applied, err := s.compactMessages(ctx, input.Mode, messages, cfg) + plan, err := s.planner.Plan(input.Mode, messages, cfg) + if err != nil { + return Result{}, err + } + if !plan.Applied { + return base, nil + } + + summary, err := s.buildSummary(ctx, input.Mode, plan, cfg) if err != nil { return Result{}, err } + next := make([]provider.Message, 0, len(plan.Retained)+1) + next = append(next, provider.Message{Role: provider.RoleAssistant, Content: summary}) + next = append(next, plan.Retained...) + afterChars := countMessageChars(next) result := base result.Messages = next - result.Applied = applied + result.Applied = true result.Metrics.AfterChars = afterChars if beforeChars > 0 { result.Metrics.SavedRatio = float64(beforeChars-afterChars) / float64(beforeChars) @@ -170,445 +170,40 @@ func (s *Service) Run(ctx context.Context, input Input) (Result, error) { return result, nil } -type atomicBlock struct { - start int - end int - messageCount int - protected bool -} - -// collectAtomicBlocks 按“不可拆分块”对消息分组,确保 tool call 与其结果不会被拆开压缩。 -func collectAtomicBlocks(messages []provider.Message) []atomicBlock { - blocks := make([]atomicBlock, 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 { - end++ - } - } - blocks = append(blocks, atomicBlock{ - start: start, - end: end, - messageCount: end - start, - protected: isProtectedThoughtBlock(messages[start:end]), - }) - i = end - } - if lastUserIndex := lastExplicitUserMessageIndex(messages); lastUserIndex >= 0 { - markBlockProtected(blocks, lastUserIndex) - } - return blocks -} - -// isProtectedThoughtBlock 为未来结构化 thought block 预留保护入口,当前默认不命中。 -func isProtectedThoughtBlock(messages []provider.Message) bool { - return false -} - -// lastExplicitUserMessageIndex 返回最后一个非空用户消息的位置,用于保护最近明确指令。 -func lastExplicitUserMessageIndex(messages []provider.Message) int { - for index := len(messages) - 1; index >= 0; index-- { - if messages[index].Role == provider.RoleUser && strings.TrimSpace(messages[index].Content) != "" { - return index - } - } - return -1 -} - -// markBlockProtected 将包含目标消息的原子块标记为受保护块。 -func markBlockProtected(blocks []atomicBlock, messageIndex int) { - for index := range blocks { - if messageIndex >= blocks[index].start && messageIndex < blocks[index].end { - blocks[index].protected = true - return - } - } -} - -// retainedStartForKeepRecent 计算 keep_recent 模式下需要保留的尾部起点。 -func retainedStartForKeepRecent(blocks []atomicBlock, keepMessages int) int { - if len(blocks) == 0 { - return 0 - } - - retainedStart := blocks[0].start - retainedMessages := 0 - for index := len(blocks) - 1; index >= 0; index-- { - retainedMessages += blocks[index].messageCount - retainedStart = blocks[index].start - if retainedMessages >= keepMessages { - break - } - } - - protectedStart, ok := protectedTailStart(blocks) - if ok && protectedStart < retainedStart { - retainedStart = protectedStart - } - return retainedStart -} - -// protectedTailStart 返回必须原样保留的受保护尾部起点。 -func protectedTailStart(blocks []atomicBlock) (int, bool) { - for index := range blocks { - if blocks[index].protected { - return blocks[index].start, true - } - } - return 0, false -} - -// splitMessagesAt 按起始下标切分 archived 与 retained 消息,并复制结果以避免共享底层切片。 -func splitMessagesAt(messages []provider.Message, retainedStart int) ([]provider.Message, []provider.Message) { - if retainedStart <= 0 { - return nil, cloneMessages(messages) - } - if retainedStart >= len(messages) { - return cloneMessages(messages), nil - } - return cloneMessages(messages[:retainedStart]), cloneMessages(messages[retainedStart:]) -} - -// compactMessages 根据当前 mode 选择实际复用的压缩策略。 -func (s *Service) compactMessages( - ctx context.Context, - mode Mode, - messages []provider.Message, - cfg config.CompactConfig, -) ([]provider.Message, bool, error) { - if mode == ModeReactive { - return s.compactKeepRecent(ctx, mode, messages, cfg) - } - - strategy := strings.ToLower(strings.TrimSpace(cfg.ManualStrategy)) - switch strategy { - case config.CompactManualStrategyKeepRecent: - return s.compactKeepRecent(ctx, mode, messages, cfg) - case config.CompactManualStrategyFullReplace: - return s.compactFullReplace(ctx, mode, messages, cfg) - default: - return nil, false, fmt.Errorf("compact: manual strategy %q is not supported", cfg.ManualStrategy) - } -} - -func (s *Service) compactKeepRecent( - ctx context.Context, - mode Mode, - messages []provider.Message, - cfg config.CompactConfig, -) ([]provider.Message, bool, error) { - blocks := collectAtomicBlocks(messages) - retainedStart := retainedStartForKeepRecent(blocks, cfg.ManualKeepRecentMessages) - if retainedStart <= 0 { - return cloneMessages(messages), false, nil - } - - removed, kept := splitMessagesAt(messages, retainedStart) - - summary, err := s.buildSummary(ctx, mode, removed, kept, len(removed), cfg) - if err != nil { - return nil, false, err - } - - next := make([]provider.Message, 0, len(kept)+1) - next = append(next, provider.Message{Role: provider.RoleAssistant, Content: summary}) - next = append(next, kept...) - return next, true, nil -} - -func (s *Service) compactFullReplace( - ctx context.Context, - mode Mode, - messages []provider.Message, - cfg config.CompactConfig, -) ([]provider.Message, bool, error) { - if len(messages) == 0 { - return nil, false, nil - } - - blocks := collectAtomicBlocks(messages) - retainedStart, hasProtectedTail := protectedTailStart(blocks) - if !hasProtectedTail { - retainedStart = len(messages) - } - - archived, retained := splitMessagesAt(messages, retainedStart) - if len(archived) == 0 { - return cloneMessages(messages), false, nil - } - - summary, err := s.buildSummary(ctx, mode, archived, retained, len(archived), cfg) - if err != nil { - return nil, false, err - } - - next := make([]provider.Message, 0, len(retained)+1) - next = append(next, provider.Message{Role: provider.RoleAssistant, Content: summary}) - next = append(next, retained...) - return next, true, nil -} - -func (s *Service) buildSummary( - ctx context.Context, - mode Mode, - archived []provider.Message, - retained []provider.Message, - archivedMessageCount int, - cfg config.CompactConfig, -) (string, error) { +// buildSummary 调用摘要生成器并委托校验器收敛最终摘要内容。 +func (s *Service) buildSummary(ctx context.Context, mode Mode, plan compactionPlan, cfg config.CompactConfig) (string, error) { if s.generator == nil { return "", errors.New("compact: summary generator is nil") } summary, err := s.generator.Generate(ctx, SummaryInput{ Mode: mode, - ArchivedMessages: cloneMessages(archived), - RetainedMessages: cloneMessages(retained), - ArchivedMessageCount: archivedMessageCount, + ArchivedMessages: cloneMessages(plan.Archived), + RetainedMessages: cloneMessages(plan.Retained), + ArchivedMessageCount: plan.ArchivedMessageCount, Config: cfg, }) if err != nil { return "", err } - return validateSummary(summary, cfg.MaxSummaryChars) -} - -func validateSummary(summary string, maxChars int) (string, error) { - summary = normalizeSummary(summary) - if maxChars > 0 { - runes := []rune(summary) - if len(runes) > maxChars { - summary = normalizeSummary(string(runes[:maxChars])) - } - } - - if err := validateSummaryStructure(summary); err != nil { - return "", err - } - return summary, nil -} - -var summarySections = internalcompact.SummarySections() - -func normalizeSummary(summary string) string { - summary = strings.ReplaceAll(summary, "\r\n", "\n") - return strings.TrimSpace(summary) -} - -func validateSummaryStructure(summary string) error { - summary = normalizeSummary(summary) - if summary == "" { - return errors.New("compact: summary is empty") - } - - lines := strings.Split(summary, "\n") - index := nextNonEmptyLine(lines, 0) - if index >= len(lines) || strings.TrimSpace(lines[index]) != internalcompact.SummaryMarker { - return fmt.Errorf("compact: summary must start with %s", internalcompact.SummaryMarker) - } - index++ - - for _, section := range summarySections { - index = nextNonEmptyLine(lines, index) - if index >= len(lines) || strings.TrimSpace(lines[index]) != section+":" { - return fmt.Errorf("compact: summary missing required section %q", section) - } - index++ - - bullets := 0 - for index < len(lines) { - rawLine := strings.TrimRight(lines[index], " \t") - line := strings.TrimSpace(rawLine) - bulletLine := strings.TrimLeft(rawLine, " \t") - switch { - case line == "": - index++ - case isSummarySectionHeader(line): - if bullets == 0 { - return fmt.Errorf("compact: summary section %q requires at least one bullet", section) - } - goto nextSection - case bulletLine == "-": - return fmt.Errorf("compact: summary section %q contains an empty bullet", section) - case !strings.HasPrefix(bulletLine, "- "): - return fmt.Errorf("compact: summary section %q contains invalid line %q", section, line) - case strings.TrimSpace(strings.TrimPrefix(bulletLine, "- ")) == "": - return fmt.Errorf("compact: summary section %q contains an empty bullet", section) - default: - bullets++ - index++ - } - } - if bullets == 0 { - return fmt.Errorf("compact: summary section %q requires at least one bullet", section) - } - - nextSection: - } - - index = nextNonEmptyLine(lines, index) - if index < len(lines) { - return fmt.Errorf("compact: summary contains unexpected trailing content %q", strings.TrimSpace(lines[index])) - } - return nil -} - -func nextNonEmptyLine(lines []string, start int) int { - for start < len(lines) && strings.TrimSpace(lines[start]) == "" { - start++ - } - return start -} - -func isSummarySectionHeader(line string) bool { - for _, section := range summarySections { - if line == section+":" { - return true - } - } - return false -} - -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"` -} - -func (s *Service) saveTranscript(messages []provider.Message, sessionID string, workdir string) (string, string, error) { - home, err := s.userHomeDir() - if err != nil { - return "", "", fmt.Errorf("compact: resolve user home: %w", err) - } - - projectHash := hashProject(workdir) - dir := transcriptDirectory(home, projectHash) - if err := s.mkdirAll(dir, 0o755); err != nil { - return "", "", fmt.Errorf("compact: create transcript dir: %w", err) - } - - sessionID = sanitizeID(sessionID) - if sessionID == "" { - sessionID = transcriptFallbackSessionID - } - tokenFn := s.randomToken - if tokenFn == nil { - tokenFn = randomTranscriptToken - } - randomToken, err := tokenFn() - if err != nil { - return "", "", fmt.Errorf("compact: generate transcript token: %w", err) - } - - transcriptID := fmt.Sprintf("transcript_%d_%s_%s", s.now().UnixNano(), randomToken, sessionID) - transcriptPath := filepath.Join(dir, transcriptID+transcriptFileExtension) - tmpPath := transcriptPath + transcriptTemporarySuffix - - now := s.now().UTC().Format(time.RFC3339Nano) - var builder strings.Builder - for i, message := range messages { - line := transcriptLine{ - Index: i, - Timestamp: now, - Role: message.Role, - Content: message.Content, - ToolCalls: append([]provider.ToolCall(nil), message.ToolCalls...), - ToolCallID: message.ToolCallID, - IsError: message.IsError, - } - payload, err := json.Marshal(line) - if err != nil { - return "", "", fmt.Errorf("compact: marshal transcript line: %w", err) - } - builder.Write(payload) - builder.WriteByte('\n') - } - - if err := s.writeFile(tmpPath, []byte(builder.String()), transcriptFileMode()); err != nil { - return "", "", fmt.Errorf("compact: write transcript: %w", err) - } - if err := s.rename(tmpPath, transcriptPath); err != nil { - _ = s.remove(tmpPath) - return "", "", fmt.Errorf("compact: commit transcript: %w", err) - } - - return transcriptID, transcriptPath, nil -} - -func transcriptFileMode() os.FileMode { - if goruntime.GOOS == "windows" { - return 0o644 - } - return 0o600 -} - -// transcriptDirectory 统一构造 compact 原始 transcript 的持久化目录。 -func transcriptDirectory(home string, projectHash string) string { - return filepath.Join(home, neocodeDataDirName, compactProjectsDirName, projectHash, compactTranscriptsDirName) -} - -func randomTranscriptToken() (string, error) { - entropy := make([]byte, 4) - if _, err := cryptorand.Read(entropy); err != nil { - return "", err - } - return hex.EncodeToString(entropy), nil -} - -func hashProject(workdir string) string { - clean := strings.TrimSpace(filepath.Clean(workdir)) - if clean == "" { - clean = "unknown" - } - sum := sha1.Sum([]byte(strings.ToLower(clean))) - return hex.EncodeToString(sum[:8]) -} - -var nonIDChars = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) - -func sanitizeID(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "" - } - return nonIDChars.ReplaceAllString(value, "_") -} - -func cloneMessages(messages []provider.Message) []provider.Message { - if len(messages) == 0 { - return nil - } - out := make([]provider.Message, 0, len(messages)) - for _, message := range messages { - next := message - next.ToolCalls = append([]provider.ToolCall(nil), message.ToolCalls...) - out = append(out, next) - } - return out + return s.summaryVerifier.Validate(summary, cfg.MaxSummaryChars) } -func countMessageChars(messages []provider.Message) int { - total := 0 - for _, message := range messages { - total += utf8.RuneCountInString(message.Role) - total += utf8.RuneCountInString(message.Content) - total += utf8.RuneCountInString(message.ToolCallID) - for _, call := range message.ToolCalls { - total += utf8.RuneCountInString(call.ID) - total += utf8.RuneCountInString(call.Name) - total += utf8.RuneCountInString(call.Arguments) - } +// transcriptStore 基于 Service 当前依赖构造 transcript 持久化服务。 +func (s *Service) transcriptStore() transcriptStore { + return transcriptStore{ + now: s.now, + randomToken: s.randomToken, + userHomeDir: s.userHomeDir, + mkdirAll: s.mkdirAll, + writeFile: s.writeFile, + rename: s.rename, + remove: s.remove, } - return total } +// normalizeCompactConfig 在 compact 执行前补齐缺失配置并收敛默认策略。 func normalizeCompactConfig(cfg config.CompactConfig) config.CompactConfig { defaults := config.Default().Context.Compact cfg.ApplyDefaults(defaults) diff --git a/internal/context/compact/summary_validator.go b/internal/context/compact/summary_validator.go new file mode 100644 index 00000000..ee62722f --- /dev/null +++ b/internal/context/compact/summary_validator.go @@ -0,0 +1,113 @@ +package compact + +import ( + "errors" + "fmt" + "strings" + + "neo-code/internal/context/internalcompact" +) + +var summarySections = internalcompact.SummarySections() + +// compactSummaryValidator 只负责 compact 摘要的规范化与结构校验。 +type compactSummaryValidator struct{} + +// Validate 校验摘要结构与长度,并返回可持久化的规范化结果。 +func (compactSummaryValidator) Validate(summary string, maxChars int) (string, error) { + summary = normalizeSummary(summary) + if maxChars > 0 { + runes := []rune(summary) + if len(runes) > maxChars { + summary = normalizeSummary(string(runes[:maxChars])) + } + } + + if err := validateSummaryStructure(summary); err != nil { + return "", err + } + return summary, nil +} + +// normalizeSummary 统一 compact 摘要换行与首尾空白,便于后续结构校验。 +func normalizeSummary(summary string) string { + summary = strings.ReplaceAll(summary, "\r\n", "\n") + return strings.TrimSpace(summary) +} + +// validateSummaryStructure 校验摘要是否满足共享协议要求的固定结构。 +func validateSummaryStructure(summary string) error { + summary = normalizeSummary(summary) + if summary == "" { + return errors.New("compact: summary is empty") + } + + lines := strings.Split(summary, "\n") + index := nextNonEmptyLine(lines, 0) + if index >= len(lines) || strings.TrimSpace(lines[index]) != internalcompact.SummaryMarker { + return fmt.Errorf("compact: summary must start with %s", internalcompact.SummaryMarker) + } + index++ + + for _, section := range summarySections { + index = nextNonEmptyLine(lines, index) + if index >= len(lines) || strings.TrimSpace(lines[index]) != section+":" { + return fmt.Errorf("compact: summary missing required section %q", section) + } + index++ + + bullets := 0 + for index < len(lines) { + rawLine := strings.TrimRight(lines[index], " \t") + line := strings.TrimSpace(rawLine) + bulletLine := strings.TrimLeft(rawLine, " \t") + switch { + case line == "": + index++ + case isSummarySectionHeader(line): + if bullets == 0 { + return fmt.Errorf("compact: summary section %q requires at least one bullet", section) + } + goto nextSection + case bulletLine == "-": + return fmt.Errorf("compact: summary section %q contains an empty bullet", section) + case !strings.HasPrefix(bulletLine, "- "): + return fmt.Errorf("compact: summary section %q contains invalid line %q", section, line) + case strings.TrimSpace(strings.TrimPrefix(bulletLine, "- ")) == "": + return fmt.Errorf("compact: summary section %q contains an empty bullet", section) + default: + bullets++ + index++ + } + } + if bullets == 0 { + return fmt.Errorf("compact: summary section %q requires at least one bullet", section) + } + + nextSection: + } + + index = nextNonEmptyLine(lines, index) + if index < len(lines) { + return fmt.Errorf("compact: summary contains unexpected trailing content %q", strings.TrimSpace(lines[index])) + } + return nil +} + +// nextNonEmptyLine 返回从给定位置开始的下一条非空白行下标。 +func nextNonEmptyLine(lines []string, start int) int { + for start < len(lines) && strings.TrimSpace(lines[start]) == "" { + start++ + } + return start +} + +// isSummarySectionHeader 判断当前行是否为 compact 摘要协议中的 section 头。 +func isSummarySectionHeader(line string) bool { + for _, section := range summarySections { + if line == section+":" { + return true + } + } + return false +} diff --git a/internal/context/compact/summary_validator_test.go b/internal/context/compact/summary_validator_test.go new file mode 100644 index 00000000..73f6b621 --- /dev/null +++ b/internal/context/compact/summary_validator_test.go @@ -0,0 +1,41 @@ +package compact + +import ( + "strings" + "testing" +) + +func TestCompactSummaryValidatorValidateAcceptsValidSummary(t *testing.T) { + t.Parallel() + + summary, err := (compactSummaryValidator{}).Validate(validSemanticSummary(), 1200) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + if !strings.Contains(summary, "done:") { + t.Fatalf("expected validated summary to preserve sections, got %q", summary) + } +} + +func TestCompactSummaryValidatorValidateRejectsBrokenStructure(t *testing.T) { + t.Parallel() + + _, err := (compactSummaryValidator{}).Validate("[compact_summary]\ndone:\n- ok", 1200) + if err == nil { + t.Fatalf("expected invalid summary error") + } +} + +func TestCompactSummaryValidatorValidateTrimsBeforeCheckingStructure(t *testing.T) { + t.Parallel() + + validator := compactSummaryValidator{} + longSummary := validSemanticSummary() + "\n\n" + got, err := validator.Validate(longSummary, len([]rune(validSemanticSummary()))) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + if got != validSemanticSummary() { + t.Fatalf("expected normalized summary, got %q", got) + } +} diff --git a/internal/context/compact/transcript_store.go b/internal/context/compact/transcript_store.go new file mode 100644 index 00000000..7ff88b44 --- /dev/null +++ b/internal/context/compact/transcript_store.go @@ -0,0 +1,157 @@ +package compact + +import ( + cryptorand "crypto/rand" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + goruntime "runtime" + "strings" + "time" + + "neo-code/internal/provider" +) + +const ( + neocodeDataDirName = ".neocode" + compactProjectsDirName = "projects" + compactTranscriptsDirName = ".transcripts" + transcriptFallbackSessionID = "draft" + transcriptFileExtension = ".jsonl" + transcriptTemporarySuffix = ".tmp" +) + +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"` +} + +// transcriptStore 只负责 compact 原始 transcript 的目录规划与安全落盘。 +type transcriptStore struct { + now func() time.Time + randomToken func() (string, error) + userHomeDir func() (string, error) + mkdirAll func(path string, perm os.FileMode) error + writeFile func(name string, data []byte, perm os.FileMode) error + rename func(oldPath, newPath string) error + remove func(path string) error +} + +// Save 按项目维度持久化当前 compact 前的 transcript,并返回 ID 与路径。 +func (s transcriptStore) Save(messages []provider.Message, sessionID string, workdir string) (string, string, error) { + home, err := s.userHomeDir() + if err != nil { + return "", "", fmt.Errorf("compact: resolve user home: %w", err) + } + + projectHash := hashProject(workdir) + dir := transcriptDirectory(home, projectHash) + if err := s.mkdirAll(dir, 0o755); err != nil { + return "", "", fmt.Errorf("compact: create transcript dir: %w", err) + } + + sessionID = sanitizeID(sessionID) + if sessionID == "" { + sessionID = transcriptFallbackSessionID + } + + tokenFn := s.randomToken + if tokenFn == nil { + tokenFn = randomTranscriptToken + } + randomToken, err := tokenFn() + if err != nil { + return "", "", fmt.Errorf("compact: generate transcript token: %w", err) + } + + transcriptID := fmt.Sprintf("transcript_%d_%s_%s", s.now().UnixNano(), randomToken, sessionID) + transcriptPath := filepath.Join(dir, transcriptID+transcriptFileExtension) + tmpPath := transcriptPath + transcriptTemporarySuffix + + now := s.now().UTC().Format(time.RFC3339Nano) + var builder strings.Builder + for i, message := range messages { + line := transcriptLine{ + Index: i, + Timestamp: now, + Role: message.Role, + Content: message.Content, + ToolCalls: append([]provider.ToolCall(nil), message.ToolCalls...), + ToolCallID: message.ToolCallID, + IsError: message.IsError, + } + payload, err := json.Marshal(line) + if err != nil { + return "", "", fmt.Errorf("compact: marshal transcript line: %w", err) + } + builder.Write(payload) + builder.WriteByte('\n') + } + + if err := s.writeFile(tmpPath, []byte(builder.String()), transcriptFileMode()); err != nil { + return "", "", fmt.Errorf("compact: write transcript: %w", err) + } + if err := s.rename(tmpPath, transcriptPath); err != nil { + _ = s.remove(tmpPath) + return "", "", fmt.Errorf("compact: commit transcript: %w", err) + } + + return transcriptID, transcriptPath, nil +} + +// transcriptFileMode 根据当前平台返回 transcript 文件权限。 +func transcriptFileMode() os.FileMode { + return transcriptFileModeForOS(goruntime.GOOS) +} + +// transcriptFileModeForOS 根据给定平台名返回 transcript 文件权限,便于测试不同分支。 +func transcriptFileModeForOS(goos string) os.FileMode { + if goos == "windows" { + return 0o644 + } + return 0o600 +} + +// transcriptDirectory 统一构造 compact 原始 transcript 的持久化目录。 +func transcriptDirectory(home string, projectHash string) string { + return filepath.Join(home, neocodeDataDirName, compactProjectsDirName, projectHash, compactTranscriptsDirName) +} + +// randomTranscriptToken 生成 transcript 文件名使用的随机令牌。 +func randomTranscriptToken() (string, error) { + entropy := make([]byte, 4) + if _, err := cryptorand.Read(entropy); err != nil { + return "", err + } + return hex.EncodeToString(entropy), nil +} + +// hashProject 使用工作目录计算项目级 transcript 目录哈希。 +func hashProject(workdir string) string { + clean := strings.TrimSpace(filepath.Clean(workdir)) + if clean == "" { + clean = "unknown" + } + sum := sha1.Sum([]byte(strings.ToLower(clean))) + return hex.EncodeToString(sum[:8]) +} + +var nonIDChars = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) + +// sanitizeID 将 session id 收敛为适合落盘文件名的安全标识。 +func sanitizeID(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + return nonIDChars.ReplaceAllString(value, "_") +} diff --git a/internal/context/compact/transcript_store_test.go b/internal/context/compact/transcript_store_test.go new file mode 100644 index 00000000..3c303d7d --- /dev/null +++ b/internal/context/compact/transcript_store_test.go @@ -0,0 +1,60 @@ +package compact + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "neo-code/internal/provider" +) + +func TestTranscriptStoreSaveSanitizesSessionIDAndWritesJSONL(t *testing.T) { + t.Parallel() + + home := t.TempDir() + store := transcriptStore{ + now: func() time.Time { return time.Unix(1712052000, 123456789) }, + randomToken: func() (string, error) { return "token1234", nil }, + userHomeDir: func() (string, error) { return home, nil }, + mkdirAll: os.MkdirAll, + writeFile: os.WriteFile, + rename: os.Rename, + remove: os.Remove, + } + + id, path, err := store.Save([]provider.Message{ + {Role: provider.RoleUser, Content: "hello"}, + }, "session with spaces", filepath.Join(home, "workspace")) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + if id == "" || path == "" { + t.Fatalf("expected transcript metadata, got id=%q path=%q", id, path) + } + if filepath.Ext(path) != transcriptFileExtension { + t.Fatalf("expected transcript extension %q, got %q", transcriptFileExtension, path) + } + if !strings.Contains(filepath.Base(path), "session_with_spaces") { + t.Fatalf("expected sanitized session id in path, got %q", path) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read transcript: %v", err) + } + if len(data) == 0 { + t.Fatalf("expected transcript content") + } +} + +func TestTranscriptFileModeForOS(t *testing.T) { + t.Parallel() + + if got := transcriptFileModeForOS("windows"); got != 0o644 { + t.Fatalf("expected windows mode 0644, got %#o", got) + } + if got := transcriptFileModeForOS("linux"); got != 0o600 { + t.Fatalf("expected non-windows mode 0600, got %#o", got) + } +} diff --git a/internal/context/prompt.go b/internal/context/prompt.go index 22cc66b7..d480e5bf 100644 --- a/internal/context/prompt.go +++ b/internal/context/prompt.go @@ -16,21 +16,9 @@ var defaultPromptSections = []promptSection{ { title: "Tool Usage", content: "- Use tools when they reduce uncertainty or are required to complete the task safely.\n" + - "- Inspect tool failures, explain the relevant error, and continue with the safest useful next step.\n" + + "- Stay within the current workspace unless the user clearly asks for something else.\n" + "- Do not claim work is done unless the needed files, commands, or verification actually succeeded.", }, - { - title: "Workspace Safety", - content: "- Stay within the current workspace unless the user clearly asks for something else.\n" + - "- Avoid destructive actions such as deleting files, rewriting unrelated work, or changing history unless explicitly requested.\n" + - "- Respect project rules and local constraints before making changes.", - }, - { - title: "Code Changes", - content: "- Prefer minimal, testable changes that keep module boundaries clear.\n" + - "- Follow the existing architecture and keep provider, runtime, tools, config, and TUI responsibilities separated.\n" + - "- When behavior changes, update the relevant tests or documentation needed to keep the implementation verifiable.", - }, { title: "Failure Recovery", content: "- If blocked, identify the concrete blocker and try the next reasonable path before giving up.\n" + diff --git a/internal/context/sources.go b/internal/context/sources.go new file mode 100644 index 00000000..1ecdbb7b --- /dev/null +++ b/internal/context/sources.go @@ -0,0 +1,50 @@ +package context + +import "context" + +// promptSectionSource 约束单个 prompt section 来源的最小能力,避免 Builder 持有具体细节。 +type promptSectionSource interface { + Sections(ctx context.Context, input BuildInput) ([]promptSection, error) +} + +// corePromptSource 只负责提供固定核心 system prompt sections。 +type corePromptSource struct{} + +// Sections 返回当前轮次都需要注入的固定核心提示。 +func (corePromptSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return append([]promptSection(nil), defaultSystemPromptSections()...), nil +} + +// projectRulesSource 只负责发现并渲染项目规则文件。 +type projectRulesSource struct{} + +// Sections 按当前工作目录向上发现 AGENTS.md,并渲染为统一 section。 +func (projectRulesSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) { + rules, err := loadProjectRules(ctx, input.Metadata.Workdir) + if err != nil { + return nil, err + } + + section := renderProjectRulesSection(rules) + if renderPromptSection(section) == "" { + return nil, nil + } + return []promptSection{section}, nil +} + +// systemStateSource 只负责收集并渲染运行时系统摘要。 +type systemStateSource struct { + gitRunner gitCommandRunner +} + +// Sections 汇总 workdir、shell、provider、model 与 git 摘要信息。 +func (s systemStateSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) { + systemState, err := collectSystemState(ctx, input.Metadata, s.gitRunner) + if err != nil { + return nil, err + } + return []promptSection{renderSystemStateSection(systemState)}, nil +} diff --git a/internal/context/sources_test.go b/internal/context/sources_test.go new file mode 100644 index 00000000..c977cca6 --- /dev/null +++ b/internal/context/sources_test.go @@ -0,0 +1,67 @@ +package context + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestCorePromptSourceSectionsReturnsClone(t *testing.T) { + t.Parallel() + + source := corePromptSource{} + first, err := source.Sections(context.Background(), BuildInput{}) + if err != nil { + t.Fatalf("Sections() error = %v", err) + } + if len(first) == 0 { + t.Fatalf("expected non-empty core prompt sections") + } + + first[0].title = "changed" + + second, err := source.Sections(context.Background(), BuildInput{}) + if err != nil { + t.Fatalf("Sections() second call error = %v", err) + } + if second[0].title != defaultPromptSections[0].title { + t.Fatalf("expected cloned sections, got %+v", second) + } +} + +func TestProjectRulesSourceSectionsSkipsWhenNoRulesExist(t *testing.T) { + t.Parallel() + + sections, err := (projectRulesSource{}).Sections(context.Background(), BuildInput{ + Metadata: Metadata{Workdir: t.TempDir()}, + }) + if err != nil { + t.Fatalf("Sections() error = %v", err) + } + if len(sections) != 0 { + t.Fatalf("expected no project rule sections, got %+v", sections) + } +} + +func TestProjectRulesSourceSectionsRendersRules(t *testing.T) { + t.Parallel() + + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, projectRuleFileName), []byte("rule-body"), 0o644); err != nil { + t.Fatalf("write AGENTS.md: %v", err) + } + + sections, err := (projectRulesSource{}).Sections(context.Background(), BuildInput{ + Metadata: Metadata{Workdir: root}, + }) + if err != nil { + t.Fatalf("Sections() error = %v", err) + } + if len(sections) != 1 { + t.Fatalf("expected one project rule section, got %+v", sections) + } + if got := renderPromptSection(sections[0]); got == "" { + t.Fatalf("expected rendered project rule section") + } +} diff --git a/internal/context/trim_policy.go b/internal/context/trim_policy.go new file mode 100644 index 00000000..e04da9a4 --- /dev/null +++ b/internal/context/trim_policy.go @@ -0,0 +1,16 @@ +package context + +import "neo-code/internal/provider" + +// messageTrimPolicy 约束消息裁剪策略的最小接口,避免 Builder 直接持有裁剪细节。 +type messageTrimPolicy interface { + Trim(messages []provider.Message) []provider.Message +} + +// spanMessageTrimPolicy 以消息 span 为单位裁剪历史,确保 tool block 不被拆散。 +type spanMessageTrimPolicy struct{} + +// Trim 返回保留关键 tool block 原子性的裁剪后消息副本。 +func (spanMessageTrimPolicy) Trim(messages []provider.Message) []provider.Message { + return trimMessages(messages) +} From e3713aa228b70093978ac2f1c596b339ca97a1e9 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sat, 4 Apr 2026 22:51:47 +0800 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=20context=20?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E4=B8=8E=20compact=20=E5=86=85=E9=83=A8?= =?UTF-8?q?=E8=81=8C=E8=B4=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/builder.go | 33 +- internal/context/compact/helpers.go | 37 ++ internal/context/compact/planner.go | 178 ++++++ internal/context/compact/planner_test.go | 75 +++ internal/context/compact/runner.go | 513 ++---------------- internal/context/compact/summary_validator.go | 113 ++++ .../context/compact/summary_validator_test.go | 41 ++ internal/context/compact/transcript_store.go | 157 ++++++ .../context/compact/transcript_store_test.go | 60 ++ internal/context/prompt.go | 14 +- internal/context/sources.go | 50 ++ internal/context/sources_test.go | 67 +++ internal/context/trim_policy.go | 16 + 13 files changed, 869 insertions(+), 485 deletions(-) create mode 100644 internal/context/compact/helpers.go create mode 100644 internal/context/compact/planner.go create mode 100644 internal/context/compact/planner_test.go create mode 100644 internal/context/compact/summary_validator.go create mode 100644 internal/context/compact/summary_validator_test.go create mode 100644 internal/context/compact/transcript_store.go create mode 100644 internal/context/compact/transcript_store_test.go create mode 100644 internal/context/sources.go create mode 100644 internal/context/sources_test.go create mode 100644 internal/context/trim_policy.go diff --git a/internal/context/builder.go b/internal/context/builder.go index abd9523a..1c7f8e30 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -4,13 +4,20 @@ import "context" // DefaultBuilder preserves the current runtime context-building behavior. type DefaultBuilder struct { - gitRunner gitCommandRunner + promptSources []promptSectionSource + trimPolicy messageTrimPolicy } // NewBuilder returns the default context builder implementation. func NewBuilder() Builder { + systemSource := systemStateSource{gitRunner: runGitCommand} return &DefaultBuilder{ - gitRunner: runGitCommand, + promptSources: []promptSectionSource{ + corePromptSource{}, + projectRulesSource{}, + systemSource, + }, + trimPolicy: spanMessageTrimPolicy{}, } } @@ -20,22 +27,22 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu return BuildResult{}, err } - rules, err := loadProjectRules(ctx, input.Metadata.Workdir) - if err != nil { - return BuildResult{}, err + sections := make([]promptSection, 0, len(b.promptSources)+1) + for _, source := range b.promptSources { + sourceSections, err := source.Sections(ctx, input) + if err != nil { + return BuildResult{}, err + } + sections = append(sections, sourceSections...) } - systemState, err := collectSystemState(ctx, input.Metadata, b.gitRunner) - if err != nil { - return BuildResult{}, err + trimPolicy := b.trimPolicy + if trimPolicy == nil { + trimPolicy = spanMessageTrimPolicy{} } - sections := append([]promptSection{}, defaultSystemPromptSections()...) - sections = append(sections, renderProjectRulesSection(rules)) - sections = append(sections, renderSystemStateSection(systemState)) - return BuildResult{ SystemPrompt: composeSystemPrompt(sections...), - Messages: trimMessages(input.Messages), + Messages: trimPolicy.Trim(input.Messages), }, nil } diff --git a/internal/context/compact/helpers.go b/internal/context/compact/helpers.go new file mode 100644 index 00000000..edbd8420 --- /dev/null +++ b/internal/context/compact/helpers.go @@ -0,0 +1,37 @@ +package compact + +import ( + "unicode/utf8" + + "neo-code/internal/provider" +) + +// cloneMessages 深拷贝消息切片,避免后续规划或摘要阶段共享底层数据。 +func cloneMessages(messages []provider.Message) []provider.Message { + if len(messages) == 0 { + return nil + } + out := make([]provider.Message, 0, len(messages)) + for _, message := range messages { + next := message + next.ToolCalls = append([]provider.ToolCall(nil), message.ToolCalls...) + out = append(out, next) + } + return out +} + +// countMessageChars 以 rune 数量统计消息体积,用于 compact 前后指标计算。 +func countMessageChars(messages []provider.Message) int { + total := 0 + for _, message := range messages { + total += utf8.RuneCountInString(message.Role) + total += utf8.RuneCountInString(message.Content) + total += utf8.RuneCountInString(message.ToolCallID) + for _, call := range message.ToolCalls { + total += utf8.RuneCountInString(call.ID) + total += utf8.RuneCountInString(call.Name) + total += utf8.RuneCountInString(call.Arguments) + } + } + return total +} diff --git a/internal/context/compact/planner.go b/internal/context/compact/planner.go new file mode 100644 index 00000000..83da831d --- /dev/null +++ b/internal/context/compact/planner.go @@ -0,0 +1,178 @@ +package compact + +import ( + "fmt" + "strings" + + "neo-code/internal/config" + "neo-code/internal/provider" +) + +// compactionPlan 描述一次 compact 在摘要生成前的归档与保留结果。 +type compactionPlan struct { + Archived []provider.Message + Retained []provider.Message + ArchivedMessageCount int + Applied bool +} + +type atomicBlock struct { + start int + end int + messageCount int + protected bool +} + +// compactionPlanner 只负责选择策略并规划 archived/retained 消息边界。 +type compactionPlanner struct{} + +// Plan 根据 mode 与配置返回摘要前的裁剪规划结果。 +func (compactionPlanner) Plan(mode Mode, messages []provider.Message, cfg config.CompactConfig) (compactionPlan, error) { + if mode == ModeReactive { + return planKeepRecent(messages, cfg.ManualKeepRecentMessages), nil + } + + switch strings.ToLower(strings.TrimSpace(cfg.ManualStrategy)) { + case config.CompactManualStrategyKeepRecent: + return planKeepRecent(messages, cfg.ManualKeepRecentMessages), nil + case config.CompactManualStrategyFullReplace: + return planFullReplace(messages), nil + default: + return compactionPlan{}, fmt.Errorf("compact: manual strategy %q is not supported", cfg.ManualStrategy) + } +} + +// planKeepRecent 计算 keep_recent 策略下需要摘要与保留的消息集合。 +func planKeepRecent(messages []provider.Message, keepMessages int) compactionPlan { + blocks := collectAtomicBlocks(messages) + retainedStart := retainedStartForKeepRecent(blocks, keepMessages) + if retainedStart <= 0 { + return compactionPlan{ + Retained: cloneMessages(messages), + Applied: false, + } + } + + archived, retained := splitMessagesAt(messages, retainedStart) + return compactionPlan{ + Archived: archived, + Retained: retained, + ArchivedMessageCount: len(archived), + Applied: len(archived) > 0, + } +} + +// planFullReplace 计算 full_replace 策略下需要摘要与保留的消息集合。 +func planFullReplace(messages []provider.Message) compactionPlan { + if len(messages) == 0 { + return compactionPlan{} + } + + blocks := collectAtomicBlocks(messages) + retainedStart, hasProtectedTail := protectedTailStart(blocks) + if !hasProtectedTail { + retainedStart = len(messages) + } + + archived, retained := splitMessagesAt(messages, retainedStart) + return compactionPlan{ + Archived: archived, + Retained: retained, + ArchivedMessageCount: len(archived), + Applied: len(archived) > 0, + } +} + +// collectAtomicBlocks 按不可拆分的消息块分组,确保 tool call 与结果始终一起保留。 +func collectAtomicBlocks(messages []provider.Message) []atomicBlock { + blocks := make([]atomicBlock, 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 { + end++ + } + } + blocks = append(blocks, atomicBlock{ + start: start, + end: end, + messageCount: end - start, + protected: isProtectedThoughtBlock(messages[start:end]), + }) + i = end + } + if lastUserIndex := lastExplicitUserMessageIndex(messages); lastUserIndex >= 0 { + markBlockProtected(blocks, lastUserIndex) + } + return blocks +} + +// isProtectedThoughtBlock 为未来结构化 thought block 预留保护入口,当前默认不命中。 +func isProtectedThoughtBlock(messages []provider.Message) bool { + return false +} + +// lastExplicitUserMessageIndex 返回最后一条非空用户消息的位置,用于保护最近明确指令。 +func lastExplicitUserMessageIndex(messages []provider.Message) int { + for index := len(messages) - 1; index >= 0; index-- { + if messages[index].Role == provider.RoleUser && strings.TrimSpace(messages[index].Content) != "" { + return index + } + } + return -1 +} + +// markBlockProtected 将包含目标消息的原子块标记为受保护块。 +func markBlockProtected(blocks []atomicBlock, messageIndex int) { + for index := range blocks { + if messageIndex >= blocks[index].start && messageIndex < blocks[index].end { + blocks[index].protected = true + return + } + } +} + +// retainedStartForKeepRecent 计算 keep_recent 策略下保留尾部的起点。 +func retainedStartForKeepRecent(blocks []atomicBlock, keepMessages int) int { + if len(blocks) == 0 { + return 0 + } + + retainedStart := blocks[0].start + retainedMessages := 0 + for index := len(blocks) - 1; index >= 0; index-- { + retainedMessages += blocks[index].messageCount + retainedStart = blocks[index].start + if retainedMessages >= keepMessages { + break + } + } + + protectedStart, ok := protectedTailStart(blocks) + if ok && protectedStart < retainedStart { + retainedStart = protectedStart + } + return retainedStart +} + +// protectedTailStart 返回必须原样保留的受保护尾部起点。 +func protectedTailStart(blocks []atomicBlock) (int, bool) { + for index := range blocks { + if blocks[index].protected { + return blocks[index].start, true + } + } + return 0, false +} + +// splitMessagesAt 按 retained 起点切分 archived 与 retained,并返回深拷贝结果。 +func splitMessagesAt(messages []provider.Message, retainedStart int) ([]provider.Message, []provider.Message) { + if retainedStart <= 0 { + return nil, cloneMessages(messages) + } + if retainedStart >= len(messages) { + return cloneMessages(messages), nil + } + return cloneMessages(messages[:retainedStart]), cloneMessages(messages[retainedStart:]) +} diff --git a/internal/context/compact/planner_test.go b/internal/context/compact/planner_test.go new file mode 100644 index 00000000..3a2a10e9 --- /dev/null +++ b/internal/context/compact/planner_test.go @@ -0,0 +1,75 @@ +package compact + +import ( + "testing" + + "neo-code/internal/config" + "neo-code/internal/provider" +) + +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"}, + }, config.CompactConfig{ + ManualStrategy: config.CompactManualStrategyKeepRecent, + ManualKeepRecentMessages: 3, + }) + if err != nil { + t.Fatalf("Plan() error = %v", err) + } + if !plan.Applied { + t.Fatalf("expected keep_recent plan applied") + } + 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 { + t.Fatalf("expected retained tool block start, got %+v", plan.Retained[0]) + } + if plan.Retained[1].Role != provider.RoleTool { + t.Fatalf("expected retained tool result, got %+v", plan.Retained[1]) + } +} + +func TestCompactionPlannerFullReplaceProtectsLatestExplicitUserInstruction(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.RoleUser, Content: "latest instruction"}, + {Role: provider.RoleAssistant, Content: "latest answer"}, + }, config.CompactConfig{ + ManualStrategy: config.CompactManualStrategyFullReplace, + }) + if err != nil { + t.Fatalf("Plan() error = %v", err) + } + if !plan.Applied { + t.Fatalf("expected full_replace plan applied") + } + 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" { + t.Fatalf("expected latest explicit user instruction to stay retained, got %+v", plan.Retained) + } +} + +func TestCompactionPlannerRejectsUnsupportedStrategy(t *testing.T) { + t.Parallel() + + _, err := (compactionPlanner{}).Plan(ModeManual, nil, config.CompactConfig{ManualStrategy: "unknown"}) + if err == nil { + t.Fatalf("expected unsupported strategy error") + } +} diff --git a/internal/context/compact/runner.go b/internal/context/compact/runner.go index 710a95be..1896c03b 100644 --- a/internal/context/compact/runner.go +++ b/internal/context/compact/runner.go @@ -2,22 +2,13 @@ package compact import ( "context" - cryptorand "crypto/rand" - "crypto/sha1" - "encoding/hex" - "encoding/json" "errors" "fmt" "os" - "path/filepath" - "regexp" - goruntime "runtime" "strings" "time" - "unicode/utf8" "neo-code/internal/config" - "neo-code/internal/context/internalcompact" "neo-code/internal/provider" ) @@ -38,15 +29,6 @@ const ( ErrorModeNone ErrorMode = "none" ) -const ( - neocodeDataDirName = ".neocode" - compactProjectsDirName = "projects" - compactTranscriptsDirName = ".transcripts" - transcriptFallbackSessionID = "draft" - transcriptFileExtension = ".jsonl" - transcriptTemporarySuffix = ".tmp" -) - // Input is a single compact execution request. type Input struct { Mode Mode @@ -95,27 +77,31 @@ type Runner interface { // Service is the default compact implementation. type Service struct { - generator SummaryGenerator - now func() time.Time - randomToken func() (string, error) - userHomeDir func() (string, error) - mkdirAll func(path string, perm os.FileMode) error - writeFile func(name string, data []byte, perm os.FileMode) error - rename func(oldPath, newPath string) error - remove func(path string) error + generator SummaryGenerator + now func() time.Time + randomToken func() (string, error) + userHomeDir func() (string, error) + mkdirAll func(path string, perm os.FileMode) error + writeFile func(name string, data []byte, perm os.FileMode) error + rename func(oldPath, newPath string) error + remove func(path string) error + planner compactionPlanner + summaryVerifier compactSummaryValidator } // NewRunner returns the default compact runner. func NewRunner(generator SummaryGenerator) *Service { return &Service{ - generator: generator, - now: time.Now, - randomToken: randomTranscriptToken, - userHomeDir: os.UserHomeDir, - mkdirAll: os.MkdirAll, - writeFile: os.WriteFile, - rename: os.Rename, - remove: os.Remove, + generator: generator, + now: time.Now, + randomToken: randomTranscriptToken, + userHomeDir: os.UserHomeDir, + mkdirAll: os.MkdirAll, + writeFile: os.WriteFile, + rename: os.Rename, + remove: os.Remove, + planner: compactionPlanner{}, + summaryVerifier: compactSummaryValidator{}, } } @@ -133,6 +119,7 @@ func (s *Service) Run(ctx context.Context, input Input) (Result, error) { if input.Mode == ModeReactive { cfg.ManualStrategy = config.CompactManualStrategyKeepRecent } + messages := cloneMessages(input.Messages) beforeChars := countMessageChars(messages) base := Result{ @@ -147,22 +134,35 @@ func (s *Service) Run(ctx context.Context, input Input) (Result, error) { }, } - transcriptID, transcriptPath, err := s.saveTranscript(messages, strings.TrimSpace(input.SessionID), strings.TrimSpace(input.Workdir)) + store := s.transcriptStore() + transcriptID, transcriptPath, err := store.Save(messages, strings.TrimSpace(input.SessionID), strings.TrimSpace(input.Workdir)) if err != nil { return Result{}, err } base.TranscriptID = transcriptID base.TranscriptPath = transcriptPath - next, applied, err := s.compactMessages(ctx, input.Mode, messages, cfg) + plan, err := s.planner.Plan(input.Mode, messages, cfg) + if err != nil { + return Result{}, err + } + if !plan.Applied { + return base, nil + } + + summary, err := s.buildSummary(ctx, input.Mode, plan, cfg) if err != nil { return Result{}, err } + next := make([]provider.Message, 0, len(plan.Retained)+1) + next = append(next, provider.Message{Role: provider.RoleAssistant, Content: summary}) + next = append(next, plan.Retained...) + afterChars := countMessageChars(next) result := base result.Messages = next - result.Applied = applied + result.Applied = true result.Metrics.AfterChars = afterChars if beforeChars > 0 { result.Metrics.SavedRatio = float64(beforeChars-afterChars) / float64(beforeChars) @@ -170,445 +170,40 @@ func (s *Service) Run(ctx context.Context, input Input) (Result, error) { return result, nil } -type atomicBlock struct { - start int - end int - messageCount int - protected bool -} - -// collectAtomicBlocks 按“不可拆分块”对消息分组,确保 tool call 与其结果不会被拆开压缩。 -func collectAtomicBlocks(messages []provider.Message) []atomicBlock { - blocks := make([]atomicBlock, 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 { - end++ - } - } - blocks = append(blocks, atomicBlock{ - start: start, - end: end, - messageCount: end - start, - protected: isProtectedThoughtBlock(messages[start:end]), - }) - i = end - } - if lastUserIndex := lastExplicitUserMessageIndex(messages); lastUserIndex >= 0 { - markBlockProtected(blocks, lastUserIndex) - } - return blocks -} - -// isProtectedThoughtBlock 为未来结构化 thought block 预留保护入口,当前默认不命中。 -func isProtectedThoughtBlock(messages []provider.Message) bool { - return false -} - -// lastExplicitUserMessageIndex 返回最后一个非空用户消息的位置,用于保护最近明确指令。 -func lastExplicitUserMessageIndex(messages []provider.Message) int { - for index := len(messages) - 1; index >= 0; index-- { - if messages[index].Role == provider.RoleUser && strings.TrimSpace(messages[index].Content) != "" { - return index - } - } - return -1 -} - -// markBlockProtected 将包含目标消息的原子块标记为受保护块。 -func markBlockProtected(blocks []atomicBlock, messageIndex int) { - for index := range blocks { - if messageIndex >= blocks[index].start && messageIndex < blocks[index].end { - blocks[index].protected = true - return - } - } -} - -// retainedStartForKeepRecent 计算 keep_recent 模式下需要保留的尾部起点。 -func retainedStartForKeepRecent(blocks []atomicBlock, keepMessages int) int { - if len(blocks) == 0 { - return 0 - } - - retainedStart := blocks[0].start - retainedMessages := 0 - for index := len(blocks) - 1; index >= 0; index-- { - retainedMessages += blocks[index].messageCount - retainedStart = blocks[index].start - if retainedMessages >= keepMessages { - break - } - } - - protectedStart, ok := protectedTailStart(blocks) - if ok && protectedStart < retainedStart { - retainedStart = protectedStart - } - return retainedStart -} - -// protectedTailStart 返回必须原样保留的受保护尾部起点。 -func protectedTailStart(blocks []atomicBlock) (int, bool) { - for index := range blocks { - if blocks[index].protected { - return blocks[index].start, true - } - } - return 0, false -} - -// splitMessagesAt 按起始下标切分 archived 与 retained 消息,并复制结果以避免共享底层切片。 -func splitMessagesAt(messages []provider.Message, retainedStart int) ([]provider.Message, []provider.Message) { - if retainedStart <= 0 { - return nil, cloneMessages(messages) - } - if retainedStart >= len(messages) { - return cloneMessages(messages), nil - } - return cloneMessages(messages[:retainedStart]), cloneMessages(messages[retainedStart:]) -} - -// compactMessages 根据当前 mode 选择实际复用的压缩策略。 -func (s *Service) compactMessages( - ctx context.Context, - mode Mode, - messages []provider.Message, - cfg config.CompactConfig, -) ([]provider.Message, bool, error) { - if mode == ModeReactive { - return s.compactKeepRecent(ctx, mode, messages, cfg) - } - - strategy := strings.ToLower(strings.TrimSpace(cfg.ManualStrategy)) - switch strategy { - case config.CompactManualStrategyKeepRecent: - return s.compactKeepRecent(ctx, mode, messages, cfg) - case config.CompactManualStrategyFullReplace: - return s.compactFullReplace(ctx, mode, messages, cfg) - default: - return nil, false, fmt.Errorf("compact: manual strategy %q is not supported", cfg.ManualStrategy) - } -} - -func (s *Service) compactKeepRecent( - ctx context.Context, - mode Mode, - messages []provider.Message, - cfg config.CompactConfig, -) ([]provider.Message, bool, error) { - blocks := collectAtomicBlocks(messages) - retainedStart := retainedStartForKeepRecent(blocks, cfg.ManualKeepRecentMessages) - if retainedStart <= 0 { - return cloneMessages(messages), false, nil - } - - removed, kept := splitMessagesAt(messages, retainedStart) - - summary, err := s.buildSummary(ctx, mode, removed, kept, len(removed), cfg) - if err != nil { - return nil, false, err - } - - next := make([]provider.Message, 0, len(kept)+1) - next = append(next, provider.Message{Role: provider.RoleAssistant, Content: summary}) - next = append(next, kept...) - return next, true, nil -} - -func (s *Service) compactFullReplace( - ctx context.Context, - mode Mode, - messages []provider.Message, - cfg config.CompactConfig, -) ([]provider.Message, bool, error) { - if len(messages) == 0 { - return nil, false, nil - } - - blocks := collectAtomicBlocks(messages) - retainedStart, hasProtectedTail := protectedTailStart(blocks) - if !hasProtectedTail { - retainedStart = len(messages) - } - - archived, retained := splitMessagesAt(messages, retainedStart) - if len(archived) == 0 { - return cloneMessages(messages), false, nil - } - - summary, err := s.buildSummary(ctx, mode, archived, retained, len(archived), cfg) - if err != nil { - return nil, false, err - } - - next := make([]provider.Message, 0, len(retained)+1) - next = append(next, provider.Message{Role: provider.RoleAssistant, Content: summary}) - next = append(next, retained...) - return next, true, nil -} - -func (s *Service) buildSummary( - ctx context.Context, - mode Mode, - archived []provider.Message, - retained []provider.Message, - archivedMessageCount int, - cfg config.CompactConfig, -) (string, error) { +// buildSummary 调用摘要生成器并委托校验器收敛最终摘要内容。 +func (s *Service) buildSummary(ctx context.Context, mode Mode, plan compactionPlan, cfg config.CompactConfig) (string, error) { if s.generator == nil { return "", errors.New("compact: summary generator is nil") } summary, err := s.generator.Generate(ctx, SummaryInput{ Mode: mode, - ArchivedMessages: cloneMessages(archived), - RetainedMessages: cloneMessages(retained), - ArchivedMessageCount: archivedMessageCount, + ArchivedMessages: cloneMessages(plan.Archived), + RetainedMessages: cloneMessages(plan.Retained), + ArchivedMessageCount: plan.ArchivedMessageCount, Config: cfg, }) if err != nil { return "", err } - return validateSummary(summary, cfg.MaxSummaryChars) -} - -func validateSummary(summary string, maxChars int) (string, error) { - summary = normalizeSummary(summary) - if maxChars > 0 { - runes := []rune(summary) - if len(runes) > maxChars { - summary = normalizeSummary(string(runes[:maxChars])) - } - } - - if err := validateSummaryStructure(summary); err != nil { - return "", err - } - return summary, nil -} - -var summarySections = internalcompact.SummarySections() - -func normalizeSummary(summary string) string { - summary = strings.ReplaceAll(summary, "\r\n", "\n") - return strings.TrimSpace(summary) -} - -func validateSummaryStructure(summary string) error { - summary = normalizeSummary(summary) - if summary == "" { - return errors.New("compact: summary is empty") - } - - lines := strings.Split(summary, "\n") - index := nextNonEmptyLine(lines, 0) - if index >= len(lines) || strings.TrimSpace(lines[index]) != internalcompact.SummaryMarker { - return fmt.Errorf("compact: summary must start with %s", internalcompact.SummaryMarker) - } - index++ - - for _, section := range summarySections { - index = nextNonEmptyLine(lines, index) - if index >= len(lines) || strings.TrimSpace(lines[index]) != section+":" { - return fmt.Errorf("compact: summary missing required section %q", section) - } - index++ - - bullets := 0 - for index < len(lines) { - rawLine := strings.TrimRight(lines[index], " \t") - line := strings.TrimSpace(rawLine) - bulletLine := strings.TrimLeft(rawLine, " \t") - switch { - case line == "": - index++ - case isSummarySectionHeader(line): - if bullets == 0 { - return fmt.Errorf("compact: summary section %q requires at least one bullet", section) - } - goto nextSection - case bulletLine == "-": - return fmt.Errorf("compact: summary section %q contains an empty bullet", section) - case !strings.HasPrefix(bulletLine, "- "): - return fmt.Errorf("compact: summary section %q contains invalid line %q", section, line) - case strings.TrimSpace(strings.TrimPrefix(bulletLine, "- ")) == "": - return fmt.Errorf("compact: summary section %q contains an empty bullet", section) - default: - bullets++ - index++ - } - } - if bullets == 0 { - return fmt.Errorf("compact: summary section %q requires at least one bullet", section) - } - - nextSection: - } - - index = nextNonEmptyLine(lines, index) - if index < len(lines) { - return fmt.Errorf("compact: summary contains unexpected trailing content %q", strings.TrimSpace(lines[index])) - } - return nil -} - -func nextNonEmptyLine(lines []string, start int) int { - for start < len(lines) && strings.TrimSpace(lines[start]) == "" { - start++ - } - return start -} - -func isSummarySectionHeader(line string) bool { - for _, section := range summarySections { - if line == section+":" { - return true - } - } - return false -} - -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"` -} - -func (s *Service) saveTranscript(messages []provider.Message, sessionID string, workdir string) (string, string, error) { - home, err := s.userHomeDir() - if err != nil { - return "", "", fmt.Errorf("compact: resolve user home: %w", err) - } - - projectHash := hashProject(workdir) - dir := transcriptDirectory(home, projectHash) - if err := s.mkdirAll(dir, 0o755); err != nil { - return "", "", fmt.Errorf("compact: create transcript dir: %w", err) - } - - sessionID = sanitizeID(sessionID) - if sessionID == "" { - sessionID = transcriptFallbackSessionID - } - tokenFn := s.randomToken - if tokenFn == nil { - tokenFn = randomTranscriptToken - } - randomToken, err := tokenFn() - if err != nil { - return "", "", fmt.Errorf("compact: generate transcript token: %w", err) - } - - transcriptID := fmt.Sprintf("transcript_%d_%s_%s", s.now().UnixNano(), randomToken, sessionID) - transcriptPath := filepath.Join(dir, transcriptID+transcriptFileExtension) - tmpPath := transcriptPath + transcriptTemporarySuffix - - now := s.now().UTC().Format(time.RFC3339Nano) - var builder strings.Builder - for i, message := range messages { - line := transcriptLine{ - Index: i, - Timestamp: now, - Role: message.Role, - Content: message.Content, - ToolCalls: append([]provider.ToolCall(nil), message.ToolCalls...), - ToolCallID: message.ToolCallID, - IsError: message.IsError, - } - payload, err := json.Marshal(line) - if err != nil { - return "", "", fmt.Errorf("compact: marshal transcript line: %w", err) - } - builder.Write(payload) - builder.WriteByte('\n') - } - - if err := s.writeFile(tmpPath, []byte(builder.String()), transcriptFileMode()); err != nil { - return "", "", fmt.Errorf("compact: write transcript: %w", err) - } - if err := s.rename(tmpPath, transcriptPath); err != nil { - _ = s.remove(tmpPath) - return "", "", fmt.Errorf("compact: commit transcript: %w", err) - } - - return transcriptID, transcriptPath, nil -} - -func transcriptFileMode() os.FileMode { - if goruntime.GOOS == "windows" { - return 0o644 - } - return 0o600 -} - -// transcriptDirectory 统一构造 compact 原始 transcript 的持久化目录。 -func transcriptDirectory(home string, projectHash string) string { - return filepath.Join(home, neocodeDataDirName, compactProjectsDirName, projectHash, compactTranscriptsDirName) -} - -func randomTranscriptToken() (string, error) { - entropy := make([]byte, 4) - if _, err := cryptorand.Read(entropy); err != nil { - return "", err - } - return hex.EncodeToString(entropy), nil -} - -func hashProject(workdir string) string { - clean := strings.TrimSpace(filepath.Clean(workdir)) - if clean == "" { - clean = "unknown" - } - sum := sha1.Sum([]byte(strings.ToLower(clean))) - return hex.EncodeToString(sum[:8]) -} - -var nonIDChars = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) - -func sanitizeID(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "" - } - return nonIDChars.ReplaceAllString(value, "_") -} - -func cloneMessages(messages []provider.Message) []provider.Message { - if len(messages) == 0 { - return nil - } - out := make([]provider.Message, 0, len(messages)) - for _, message := range messages { - next := message - next.ToolCalls = append([]provider.ToolCall(nil), message.ToolCalls...) - out = append(out, next) - } - return out + return s.summaryVerifier.Validate(summary, cfg.MaxSummaryChars) } -func countMessageChars(messages []provider.Message) int { - total := 0 - for _, message := range messages { - total += utf8.RuneCountInString(message.Role) - total += utf8.RuneCountInString(message.Content) - total += utf8.RuneCountInString(message.ToolCallID) - for _, call := range message.ToolCalls { - total += utf8.RuneCountInString(call.ID) - total += utf8.RuneCountInString(call.Name) - total += utf8.RuneCountInString(call.Arguments) - } +// transcriptStore 基于 Service 当前依赖构造 transcript 持久化服务。 +func (s *Service) transcriptStore() transcriptStore { + return transcriptStore{ + now: s.now, + randomToken: s.randomToken, + userHomeDir: s.userHomeDir, + mkdirAll: s.mkdirAll, + writeFile: s.writeFile, + rename: s.rename, + remove: s.remove, } - return total } +// normalizeCompactConfig 在 compact 执行前补齐缺失配置并收敛默认策略。 func normalizeCompactConfig(cfg config.CompactConfig) config.CompactConfig { defaults := config.Default().Context.Compact cfg.ApplyDefaults(defaults) diff --git a/internal/context/compact/summary_validator.go b/internal/context/compact/summary_validator.go new file mode 100644 index 00000000..ee62722f --- /dev/null +++ b/internal/context/compact/summary_validator.go @@ -0,0 +1,113 @@ +package compact + +import ( + "errors" + "fmt" + "strings" + + "neo-code/internal/context/internalcompact" +) + +var summarySections = internalcompact.SummarySections() + +// compactSummaryValidator 只负责 compact 摘要的规范化与结构校验。 +type compactSummaryValidator struct{} + +// Validate 校验摘要结构与长度,并返回可持久化的规范化结果。 +func (compactSummaryValidator) Validate(summary string, maxChars int) (string, error) { + summary = normalizeSummary(summary) + if maxChars > 0 { + runes := []rune(summary) + if len(runes) > maxChars { + summary = normalizeSummary(string(runes[:maxChars])) + } + } + + if err := validateSummaryStructure(summary); err != nil { + return "", err + } + return summary, nil +} + +// normalizeSummary 统一 compact 摘要换行与首尾空白,便于后续结构校验。 +func normalizeSummary(summary string) string { + summary = strings.ReplaceAll(summary, "\r\n", "\n") + return strings.TrimSpace(summary) +} + +// validateSummaryStructure 校验摘要是否满足共享协议要求的固定结构。 +func validateSummaryStructure(summary string) error { + summary = normalizeSummary(summary) + if summary == "" { + return errors.New("compact: summary is empty") + } + + lines := strings.Split(summary, "\n") + index := nextNonEmptyLine(lines, 0) + if index >= len(lines) || strings.TrimSpace(lines[index]) != internalcompact.SummaryMarker { + return fmt.Errorf("compact: summary must start with %s", internalcompact.SummaryMarker) + } + index++ + + for _, section := range summarySections { + index = nextNonEmptyLine(lines, index) + if index >= len(lines) || strings.TrimSpace(lines[index]) != section+":" { + return fmt.Errorf("compact: summary missing required section %q", section) + } + index++ + + bullets := 0 + for index < len(lines) { + rawLine := strings.TrimRight(lines[index], " \t") + line := strings.TrimSpace(rawLine) + bulletLine := strings.TrimLeft(rawLine, " \t") + switch { + case line == "": + index++ + case isSummarySectionHeader(line): + if bullets == 0 { + return fmt.Errorf("compact: summary section %q requires at least one bullet", section) + } + goto nextSection + case bulletLine == "-": + return fmt.Errorf("compact: summary section %q contains an empty bullet", section) + case !strings.HasPrefix(bulletLine, "- "): + return fmt.Errorf("compact: summary section %q contains invalid line %q", section, line) + case strings.TrimSpace(strings.TrimPrefix(bulletLine, "- ")) == "": + return fmt.Errorf("compact: summary section %q contains an empty bullet", section) + default: + bullets++ + index++ + } + } + if bullets == 0 { + return fmt.Errorf("compact: summary section %q requires at least one bullet", section) + } + + nextSection: + } + + index = nextNonEmptyLine(lines, index) + if index < len(lines) { + return fmt.Errorf("compact: summary contains unexpected trailing content %q", strings.TrimSpace(lines[index])) + } + return nil +} + +// nextNonEmptyLine 返回从给定位置开始的下一条非空白行下标。 +func nextNonEmptyLine(lines []string, start int) int { + for start < len(lines) && strings.TrimSpace(lines[start]) == "" { + start++ + } + return start +} + +// isSummarySectionHeader 判断当前行是否为 compact 摘要协议中的 section 头。 +func isSummarySectionHeader(line string) bool { + for _, section := range summarySections { + if line == section+":" { + return true + } + } + return false +} diff --git a/internal/context/compact/summary_validator_test.go b/internal/context/compact/summary_validator_test.go new file mode 100644 index 00000000..73f6b621 --- /dev/null +++ b/internal/context/compact/summary_validator_test.go @@ -0,0 +1,41 @@ +package compact + +import ( + "strings" + "testing" +) + +func TestCompactSummaryValidatorValidateAcceptsValidSummary(t *testing.T) { + t.Parallel() + + summary, err := (compactSummaryValidator{}).Validate(validSemanticSummary(), 1200) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + if !strings.Contains(summary, "done:") { + t.Fatalf("expected validated summary to preserve sections, got %q", summary) + } +} + +func TestCompactSummaryValidatorValidateRejectsBrokenStructure(t *testing.T) { + t.Parallel() + + _, err := (compactSummaryValidator{}).Validate("[compact_summary]\ndone:\n- ok", 1200) + if err == nil { + t.Fatalf("expected invalid summary error") + } +} + +func TestCompactSummaryValidatorValidateTrimsBeforeCheckingStructure(t *testing.T) { + t.Parallel() + + validator := compactSummaryValidator{} + longSummary := validSemanticSummary() + "\n\n" + got, err := validator.Validate(longSummary, len([]rune(validSemanticSummary()))) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + if got != validSemanticSummary() { + t.Fatalf("expected normalized summary, got %q", got) + } +} diff --git a/internal/context/compact/transcript_store.go b/internal/context/compact/transcript_store.go new file mode 100644 index 00000000..7ff88b44 --- /dev/null +++ b/internal/context/compact/transcript_store.go @@ -0,0 +1,157 @@ +package compact + +import ( + cryptorand "crypto/rand" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + goruntime "runtime" + "strings" + "time" + + "neo-code/internal/provider" +) + +const ( + neocodeDataDirName = ".neocode" + compactProjectsDirName = "projects" + compactTranscriptsDirName = ".transcripts" + transcriptFallbackSessionID = "draft" + transcriptFileExtension = ".jsonl" + transcriptTemporarySuffix = ".tmp" +) + +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"` +} + +// transcriptStore 只负责 compact 原始 transcript 的目录规划与安全落盘。 +type transcriptStore struct { + now func() time.Time + randomToken func() (string, error) + userHomeDir func() (string, error) + mkdirAll func(path string, perm os.FileMode) error + writeFile func(name string, data []byte, perm os.FileMode) error + rename func(oldPath, newPath string) error + remove func(path string) error +} + +// Save 按项目维度持久化当前 compact 前的 transcript,并返回 ID 与路径。 +func (s transcriptStore) Save(messages []provider.Message, sessionID string, workdir string) (string, string, error) { + home, err := s.userHomeDir() + if err != nil { + return "", "", fmt.Errorf("compact: resolve user home: %w", err) + } + + projectHash := hashProject(workdir) + dir := transcriptDirectory(home, projectHash) + if err := s.mkdirAll(dir, 0o755); err != nil { + return "", "", fmt.Errorf("compact: create transcript dir: %w", err) + } + + sessionID = sanitizeID(sessionID) + if sessionID == "" { + sessionID = transcriptFallbackSessionID + } + + tokenFn := s.randomToken + if tokenFn == nil { + tokenFn = randomTranscriptToken + } + randomToken, err := tokenFn() + if err != nil { + return "", "", fmt.Errorf("compact: generate transcript token: %w", err) + } + + transcriptID := fmt.Sprintf("transcript_%d_%s_%s", s.now().UnixNano(), randomToken, sessionID) + transcriptPath := filepath.Join(dir, transcriptID+transcriptFileExtension) + tmpPath := transcriptPath + transcriptTemporarySuffix + + now := s.now().UTC().Format(time.RFC3339Nano) + var builder strings.Builder + for i, message := range messages { + line := transcriptLine{ + Index: i, + Timestamp: now, + Role: message.Role, + Content: message.Content, + ToolCalls: append([]provider.ToolCall(nil), message.ToolCalls...), + ToolCallID: message.ToolCallID, + IsError: message.IsError, + } + payload, err := json.Marshal(line) + if err != nil { + return "", "", fmt.Errorf("compact: marshal transcript line: %w", err) + } + builder.Write(payload) + builder.WriteByte('\n') + } + + if err := s.writeFile(tmpPath, []byte(builder.String()), transcriptFileMode()); err != nil { + return "", "", fmt.Errorf("compact: write transcript: %w", err) + } + if err := s.rename(tmpPath, transcriptPath); err != nil { + _ = s.remove(tmpPath) + return "", "", fmt.Errorf("compact: commit transcript: %w", err) + } + + return transcriptID, transcriptPath, nil +} + +// transcriptFileMode 根据当前平台返回 transcript 文件权限。 +func transcriptFileMode() os.FileMode { + return transcriptFileModeForOS(goruntime.GOOS) +} + +// transcriptFileModeForOS 根据给定平台名返回 transcript 文件权限,便于测试不同分支。 +func transcriptFileModeForOS(goos string) os.FileMode { + if goos == "windows" { + return 0o644 + } + return 0o600 +} + +// transcriptDirectory 统一构造 compact 原始 transcript 的持久化目录。 +func transcriptDirectory(home string, projectHash string) string { + return filepath.Join(home, neocodeDataDirName, compactProjectsDirName, projectHash, compactTranscriptsDirName) +} + +// randomTranscriptToken 生成 transcript 文件名使用的随机令牌。 +func randomTranscriptToken() (string, error) { + entropy := make([]byte, 4) + if _, err := cryptorand.Read(entropy); err != nil { + return "", err + } + return hex.EncodeToString(entropy), nil +} + +// hashProject 使用工作目录计算项目级 transcript 目录哈希。 +func hashProject(workdir string) string { + clean := strings.TrimSpace(filepath.Clean(workdir)) + if clean == "" { + clean = "unknown" + } + sum := sha1.Sum([]byte(strings.ToLower(clean))) + return hex.EncodeToString(sum[:8]) +} + +var nonIDChars = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) + +// sanitizeID 将 session id 收敛为适合落盘文件名的安全标识。 +func sanitizeID(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + return nonIDChars.ReplaceAllString(value, "_") +} diff --git a/internal/context/compact/transcript_store_test.go b/internal/context/compact/transcript_store_test.go new file mode 100644 index 00000000..3c303d7d --- /dev/null +++ b/internal/context/compact/transcript_store_test.go @@ -0,0 +1,60 @@ +package compact + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "neo-code/internal/provider" +) + +func TestTranscriptStoreSaveSanitizesSessionIDAndWritesJSONL(t *testing.T) { + t.Parallel() + + home := t.TempDir() + store := transcriptStore{ + now: func() time.Time { return time.Unix(1712052000, 123456789) }, + randomToken: func() (string, error) { return "token1234", nil }, + userHomeDir: func() (string, error) { return home, nil }, + mkdirAll: os.MkdirAll, + writeFile: os.WriteFile, + rename: os.Rename, + remove: os.Remove, + } + + id, path, err := store.Save([]provider.Message{ + {Role: provider.RoleUser, Content: "hello"}, + }, "session with spaces", filepath.Join(home, "workspace")) + if err != nil { + t.Fatalf("Save() error = %v", err) + } + if id == "" || path == "" { + t.Fatalf("expected transcript metadata, got id=%q path=%q", id, path) + } + if filepath.Ext(path) != transcriptFileExtension { + t.Fatalf("expected transcript extension %q, got %q", transcriptFileExtension, path) + } + if !strings.Contains(filepath.Base(path), "session_with_spaces") { + t.Fatalf("expected sanitized session id in path, got %q", path) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read transcript: %v", err) + } + if len(data) == 0 { + t.Fatalf("expected transcript content") + } +} + +func TestTranscriptFileModeForOS(t *testing.T) { + t.Parallel() + + if got := transcriptFileModeForOS("windows"); got != 0o644 { + t.Fatalf("expected windows mode 0644, got %#o", got) + } + if got := transcriptFileModeForOS("linux"); got != 0o600 { + t.Fatalf("expected non-windows mode 0600, got %#o", got) + } +} diff --git a/internal/context/prompt.go b/internal/context/prompt.go index 22cc66b7..d480e5bf 100644 --- a/internal/context/prompt.go +++ b/internal/context/prompt.go @@ -16,21 +16,9 @@ var defaultPromptSections = []promptSection{ { title: "Tool Usage", content: "- Use tools when they reduce uncertainty or are required to complete the task safely.\n" + - "- Inspect tool failures, explain the relevant error, and continue with the safest useful next step.\n" + + "- Stay within the current workspace unless the user clearly asks for something else.\n" + "- Do not claim work is done unless the needed files, commands, or verification actually succeeded.", }, - { - title: "Workspace Safety", - content: "- Stay within the current workspace unless the user clearly asks for something else.\n" + - "- Avoid destructive actions such as deleting files, rewriting unrelated work, or changing history unless explicitly requested.\n" + - "- Respect project rules and local constraints before making changes.", - }, - { - title: "Code Changes", - content: "- Prefer minimal, testable changes that keep module boundaries clear.\n" + - "- Follow the existing architecture and keep provider, runtime, tools, config, and TUI responsibilities separated.\n" + - "- When behavior changes, update the relevant tests or documentation needed to keep the implementation verifiable.", - }, { title: "Failure Recovery", content: "- If blocked, identify the concrete blocker and try the next reasonable path before giving up.\n" + diff --git a/internal/context/sources.go b/internal/context/sources.go new file mode 100644 index 00000000..1ecdbb7b --- /dev/null +++ b/internal/context/sources.go @@ -0,0 +1,50 @@ +package context + +import "context" + +// promptSectionSource 约束单个 prompt section 来源的最小能力,避免 Builder 持有具体细节。 +type promptSectionSource interface { + Sections(ctx context.Context, input BuildInput) ([]promptSection, error) +} + +// corePromptSource 只负责提供固定核心 system prompt sections。 +type corePromptSource struct{} + +// Sections 返回当前轮次都需要注入的固定核心提示。 +func (corePromptSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + return append([]promptSection(nil), defaultSystemPromptSections()...), nil +} + +// projectRulesSource 只负责发现并渲染项目规则文件。 +type projectRulesSource struct{} + +// Sections 按当前工作目录向上发现 AGENTS.md,并渲染为统一 section。 +func (projectRulesSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) { + rules, err := loadProjectRules(ctx, input.Metadata.Workdir) + if err != nil { + return nil, err + } + + section := renderProjectRulesSection(rules) + if renderPromptSection(section) == "" { + return nil, nil + } + return []promptSection{section}, nil +} + +// systemStateSource 只负责收集并渲染运行时系统摘要。 +type systemStateSource struct { + gitRunner gitCommandRunner +} + +// Sections 汇总 workdir、shell、provider、model 与 git 摘要信息。 +func (s systemStateSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) { + systemState, err := collectSystemState(ctx, input.Metadata, s.gitRunner) + if err != nil { + return nil, err + } + return []promptSection{renderSystemStateSection(systemState)}, nil +} diff --git a/internal/context/sources_test.go b/internal/context/sources_test.go new file mode 100644 index 00000000..c977cca6 --- /dev/null +++ b/internal/context/sources_test.go @@ -0,0 +1,67 @@ +package context + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestCorePromptSourceSectionsReturnsClone(t *testing.T) { + t.Parallel() + + source := corePromptSource{} + first, err := source.Sections(context.Background(), BuildInput{}) + if err != nil { + t.Fatalf("Sections() error = %v", err) + } + if len(first) == 0 { + t.Fatalf("expected non-empty core prompt sections") + } + + first[0].title = "changed" + + second, err := source.Sections(context.Background(), BuildInput{}) + if err != nil { + t.Fatalf("Sections() second call error = %v", err) + } + if second[0].title != defaultPromptSections[0].title { + t.Fatalf("expected cloned sections, got %+v", second) + } +} + +func TestProjectRulesSourceSectionsSkipsWhenNoRulesExist(t *testing.T) { + t.Parallel() + + sections, err := (projectRulesSource{}).Sections(context.Background(), BuildInput{ + Metadata: Metadata{Workdir: t.TempDir()}, + }) + if err != nil { + t.Fatalf("Sections() error = %v", err) + } + if len(sections) != 0 { + t.Fatalf("expected no project rule sections, got %+v", sections) + } +} + +func TestProjectRulesSourceSectionsRendersRules(t *testing.T) { + t.Parallel() + + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, projectRuleFileName), []byte("rule-body"), 0o644); err != nil { + t.Fatalf("write AGENTS.md: %v", err) + } + + sections, err := (projectRulesSource{}).Sections(context.Background(), BuildInput{ + Metadata: Metadata{Workdir: root}, + }) + if err != nil { + t.Fatalf("Sections() error = %v", err) + } + if len(sections) != 1 { + t.Fatalf("expected one project rule section, got %+v", sections) + } + if got := renderPromptSection(sections[0]); got == "" { + t.Fatalf("expected rendered project rule section") + } +} diff --git a/internal/context/trim_policy.go b/internal/context/trim_policy.go new file mode 100644 index 00000000..e04da9a4 --- /dev/null +++ b/internal/context/trim_policy.go @@ -0,0 +1,16 @@ +package context + +import "neo-code/internal/provider" + +// messageTrimPolicy 约束消息裁剪策略的最小接口,避免 Builder 直接持有裁剪细节。 +type messageTrimPolicy interface { + Trim(messages []provider.Message) []provider.Message +} + +// spanMessageTrimPolicy 以消息 span 为单位裁剪历史,确保 tool block 不被拆散。 +type spanMessageTrimPolicy struct{} + +// Trim 返回保留关键 tool block 原子性的裁剪后消息副本。 +func (spanMessageTrimPolicy) Trim(messages []provider.Message) []provider.Message { + return trimMessages(messages) +} From 6f0f2d0d2dacabfd02777be0dd283046c20a954e Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 5 Apr 2026 09:27:12 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E4=BC=98=E5=8C=96=20context=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=86=85=E9=83=A8=E8=A3=81=E5=89=AA=E4=B8=8E=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/builder.go | 4 +- internal/context/builder_test.go | 70 +++++ internal/context/compact/planner.go | 101 +------ internal/context/compact/runner_test.go | 2 +- internal/context/compact/summary_validator.go | 275 ++++++++++++++++-- .../context/compact/summary_validator_test.go | 34 ++- internal/context/compact_prompt.go | 65 ++++- internal/context/compact_prompt_test.go | 24 +- internal/context/internalcompact/messages.go | 93 ++++++ .../context/internalcompact/messages_test.go | 58 ++++ internal/context/source_rules.go | 142 +++++++++ internal/context/source_rules_test.go | 45 +++ internal/context/source_system.go | 54 +++- internal/context/source_system_test.go | 15 +- internal/context/sources.go | 40 ++- internal/context/sources_test.go | 4 +- internal/context/trim.go | 35 +-- internal/runtime/compact_generator_test.go | 17 +- internal/runtime/runtime_test.go | 8 +- 19 files changed, 888 insertions(+), 198 deletions(-) create mode 100644 internal/context/internalcompact/messages.go create mode 100644 internal/context/internalcompact/messages_test.go diff --git a/internal/context/builder.go b/internal/context/builder.go index 1c7f8e30..ff1487ea 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -10,11 +10,11 @@ type DefaultBuilder struct { // NewBuilder returns the default context builder implementation. func NewBuilder() Builder { - systemSource := systemStateSource{gitRunner: runGitCommand} + systemSource := &systemStateSource{gitRunner: runGitCommand} return &DefaultBuilder{ promptSources: []promptSectionSource{ corePromptSource{}, - projectRulesSource{}, + &projectRulesSource{}, systemSource, }, trimPolicy: spanMessageTrimPolicy{}, diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 84d03774..141e3737 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "neo-code/internal/context/internalcompact" "neo-code/internal/provider" ) @@ -132,6 +133,75 @@ func TestTrimMessagesPreservesToolPairs(t *testing.T) { } } +func TestTrimMessagesProtectsLatestExplicitUserInstructionTail(t *testing.T) { + t.Parallel() + + messages := make([]provider.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, + 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"}, + ) + + trimmed := trimMessages(messages) + if trimmed[0].Role != provider.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 { + t.Fatalf("expected protected tail to keep latest instruction and full assistant tail, got %d messages", len(trimmed)) + } +} + +func TestTrimMessagesUsesSharedSpanModel(t *testing.T) { + t.Parallel() + + messages := make([]provider.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, + provider.Message{ + Role: provider.RoleAssistant, + ToolCalls: []provider.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"}, + ) + + spans := internalcompact.BuildMessageSpans(messages) + trimmed := trimMessages(messages) + + start := spans[len(spans)-maxRetainedMessageSpans].Start + 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 { + t.Fatalf("expected trim to keep whole tool block at shared boundary, got %+v", trimmed[0]) + } +} + func TestTrimMessagesBoundaries(t *testing.T) { t.Parallel() diff --git a/internal/context/compact/planner.go b/internal/context/compact/planner.go index 83da831d..7f06c043 100644 --- a/internal/context/compact/planner.go +++ b/internal/context/compact/planner.go @@ -5,6 +5,7 @@ import ( "strings" "neo-code/internal/config" + "neo-code/internal/context/internalcompact" "neo-code/internal/provider" ) @@ -16,14 +17,7 @@ type compactionPlan struct { Applied bool } -type atomicBlock struct { - start int - end int - messageCount int - protected bool -} - -// compactionPlanner 只负责选择策略并规划 archived/retained 消息边界。 +// compactionPlanner 负责根据策略规划 archived 和 retained 的消息边界。 type compactionPlanner struct{} // Plan 根据 mode 与配置返回摘要前的裁剪规划结果。 @@ -44,8 +38,8 @@ func (compactionPlanner) Plan(mode Mode, messages []provider.Message, cfg config // planKeepRecent 计算 keep_recent 策略下需要摘要与保留的消息集合。 func planKeepRecent(messages []provider.Message, keepMessages int) compactionPlan { - blocks := collectAtomicBlocks(messages) - retainedStart := retainedStartForKeepRecent(blocks, keepMessages) + spans := internalcompact.BuildMessageSpans(messages) + retainedStart := internalcompact.RetainedStartForKeepRecentMessages(spans, keepMessages) if retainedStart <= 0 { return compactionPlan{ Retained: cloneMessages(messages), @@ -68,8 +62,8 @@ func planFullReplace(messages []provider.Message) compactionPlan { return compactionPlan{} } - blocks := collectAtomicBlocks(messages) - retainedStart, hasProtectedTail := protectedTailStart(blocks) + spans := internalcompact.BuildMessageSpans(messages) + retainedStart, hasProtectedTail := internalcompact.ProtectedTailStart(spans) if !hasProtectedTail { retainedStart = len(messages) } @@ -83,89 +77,6 @@ func planFullReplace(messages []provider.Message) compactionPlan { } } -// collectAtomicBlocks 按不可拆分的消息块分组,确保 tool call 与结果始终一起保留。 -func collectAtomicBlocks(messages []provider.Message) []atomicBlock { - blocks := make([]atomicBlock, 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 { - end++ - } - } - blocks = append(blocks, atomicBlock{ - start: start, - end: end, - messageCount: end - start, - protected: isProtectedThoughtBlock(messages[start:end]), - }) - i = end - } - if lastUserIndex := lastExplicitUserMessageIndex(messages); lastUserIndex >= 0 { - markBlockProtected(blocks, lastUserIndex) - } - return blocks -} - -// isProtectedThoughtBlock 为未来结构化 thought block 预留保护入口,当前默认不命中。 -func isProtectedThoughtBlock(messages []provider.Message) bool { - return false -} - -// lastExplicitUserMessageIndex 返回最后一条非空用户消息的位置,用于保护最近明确指令。 -func lastExplicitUserMessageIndex(messages []provider.Message) int { - for index := len(messages) - 1; index >= 0; index-- { - if messages[index].Role == provider.RoleUser && strings.TrimSpace(messages[index].Content) != "" { - return index - } - } - return -1 -} - -// markBlockProtected 将包含目标消息的原子块标记为受保护块。 -func markBlockProtected(blocks []atomicBlock, messageIndex int) { - for index := range blocks { - if messageIndex >= blocks[index].start && messageIndex < blocks[index].end { - blocks[index].protected = true - return - } - } -} - -// retainedStartForKeepRecent 计算 keep_recent 策略下保留尾部的起点。 -func retainedStartForKeepRecent(blocks []atomicBlock, keepMessages int) int { - if len(blocks) == 0 { - return 0 - } - - retainedStart := blocks[0].start - retainedMessages := 0 - for index := len(blocks) - 1; index >= 0; index-- { - retainedMessages += blocks[index].messageCount - retainedStart = blocks[index].start - if retainedMessages >= keepMessages { - break - } - } - - protectedStart, ok := protectedTailStart(blocks) - if ok && protectedStart < retainedStart { - retainedStart = protectedStart - } - return retainedStart -} - -// protectedTailStart 返回必须原样保留的受保护尾部起点。 -func protectedTailStart(blocks []atomicBlock) (int, bool) { - for index := range blocks { - if blocks[index].protected { - return blocks[index].start, true - } - } - return 0, false -} - // splitMessagesAt 按 retained 起点切分 archived 与 retained,并返回深拷贝结果。 func splitMessagesAt(messages []provider.Message, retainedStart int) ([]provider.Message, []provider.Message) { if retainedStart <= 0 { diff --git a/internal/context/compact/runner_test.go b/internal/context/compact/runner_test.go index 46fa4f74..4a0e05a7 100644 --- a/internal/context/compact/runner_test.go +++ b/internal/context/compact/runner_test.go @@ -591,7 +591,7 @@ func TestManualCompactTruncationFailsWhenStructureBreaks(t *testing.T) { MaxSummaryChars: 40, }, }) - if err == nil || !strings.Contains(err.Error(), "missing required section") { + if err == nil || !strings.Contains(err.Error(), "max_summary_chars") { t.Fatalf("expected truncation validation failure, got %v", err) } } diff --git a/internal/context/compact/summary_validator.go b/internal/context/compact/summary_validator.go index ee62722f..90dea9fa 100644 --- a/internal/context/compact/summary_validator.go +++ b/internal/context/compact/summary_validator.go @@ -10,23 +10,33 @@ import ( var summarySections = internalcompact.SummarySections() -// compactSummaryValidator 只负责 compact 摘要的规范化与结构校验。 +type parsedSummary struct { + Sections []parsedSummarySection +} + +type parsedSummarySection struct { + Name string + Bullets []string +} + +// compactSummaryValidator 负责 compact 摘要的规范化、结构校验与结构化收缩。 type compactSummaryValidator struct{} -// Validate 校验摘要结构与长度,并返回可持久化的规范化结果。 +// Validate 校验摘要结构与长度,并在可修复时按 section 收缩到目标上限内。 func (compactSummaryValidator) Validate(summary string, maxChars int) (string, error) { - summary = normalizeSummary(summary) + document, err := parseCompactSummary(summary) + if err != nil { + return "", err + } + if maxChars > 0 { - runes := []rune(summary) - if len(runes) > maxChars { - summary = normalizeSummary(string(runes[:maxChars])) + document, err = shrinkSummaryDocument(document, maxChars) + if err != nil { + return "", err } } - if err := validateSummaryStructure(summary); err != nil { - return "", err - } - return summary, nil + return renderSummaryDocument(document), nil } // normalizeSummary 统一 compact 摘要换行与首尾空白,便于后续结构校验。 @@ -35,63 +45,256 @@ func normalizeSummary(summary string) string { return strings.TrimSpace(summary) } -// validateSummaryStructure 校验摘要是否满足共享协议要求的固定结构。 -func validateSummaryStructure(summary string) error { +// parseCompactSummary 按固定协议解析 compact 摘要,并拒绝缺失 section 或非法尾部内容。 +func parseCompactSummary(summary string) (parsedSummary, error) { summary = normalizeSummary(summary) if summary == "" { - return errors.New("compact: summary is empty") + return parsedSummary{}, errors.New("compact: summary is empty") } lines := strings.Split(summary, "\n") index := nextNonEmptyLine(lines, 0) if index >= len(lines) || strings.TrimSpace(lines[index]) != internalcompact.SummaryMarker { - return fmt.Errorf("compact: summary must start with %s", internalcompact.SummaryMarker) + return parsedSummary{}, fmt.Errorf("compact: summary must start with %s", internalcompact.SummaryMarker) } index++ + document := parsedSummary{ + Sections: make([]parsedSummarySection, 0, len(summarySections)), + } for _, section := range summarySections { index = nextNonEmptyLine(lines, index) if index >= len(lines) || strings.TrimSpace(lines[index]) != section+":" { - return fmt.Errorf("compact: summary missing required section %q", section) + return parsedSummary{}, fmt.Errorf("compact: summary missing required section %q", section) } index++ - bullets := 0 + bullets := make([]string, 0, 2) for index < len(lines) { rawLine := strings.TrimRight(lines[index], " \t") line := strings.TrimSpace(rawLine) bulletLine := strings.TrimLeft(rawLine, " \t") + switch { case line == "": index++ case isSummarySectionHeader(line): - if bullets == 0 { - return fmt.Errorf("compact: summary section %q requires at least one bullet", section) + if len(bullets) == 0 { + return parsedSummary{}, fmt.Errorf("compact: summary section %q requires at least one bullet", section) } goto nextSection case bulletLine == "-": - return fmt.Errorf("compact: summary section %q contains an empty bullet", section) + return parsedSummary{}, fmt.Errorf("compact: summary section %q contains an empty bullet", section) case !strings.HasPrefix(bulletLine, "- "): - return fmt.Errorf("compact: summary section %q contains invalid line %q", section, line) - case strings.TrimSpace(strings.TrimPrefix(bulletLine, "- ")) == "": - return fmt.Errorf("compact: summary section %q contains an empty bullet", section) + return parsedSummary{}, fmt.Errorf("compact: summary section %q contains invalid line %q", section, line) default: - bullets++ + content := strings.TrimSpace(strings.TrimPrefix(bulletLine, "- ")) + if content == "" { + return parsedSummary{}, fmt.Errorf("compact: summary section %q contains an empty bullet", section) + } + bullets = append(bullets, content) index++ } } - if bullets == 0 { - return fmt.Errorf("compact: summary section %q requires at least one bullet", section) + + if len(bullets) == 0 { + return parsedSummary{}, fmt.Errorf("compact: summary section %q requires at least one bullet", section) } nextSection: + document.Sections = append(document.Sections, parsedSummarySection{ + Name: section, + Bullets: bullets, + }) } index = nextNonEmptyLine(lines, index) if index < len(lines) { - return fmt.Errorf("compact: summary contains unexpected trailing content %q", strings.TrimSpace(lines[index])) + return parsedSummary{}, fmt.Errorf("compact: summary contains unexpected trailing content %q", strings.TrimSpace(lines[index])) + } + return document, nil +} + +// shrinkSummaryDocument 尝试在不破坏协议的前提下收缩摘要,优先保留结构合法性。 +func shrinkSummaryDocument(document parsedSummary, maxChars int) (parsedSummary, error) { + normalized := normalizeSummaryDocument(document) + if maxChars <= 0 || runeCount(renderSummaryDocument(normalized)) <= maxChars { + return normalized, nil + } + + singleBullet := keepFirstBulletPerSection(normalized) + if runeCount(renderSummaryDocument(singleBullet)) <= maxChars { + return singleBullet, nil + } + + shrunk, ok := fitSummaryDocument(singleBullet, maxChars) + if !ok { + return parsedSummary{}, fmt.Errorf("compact: summary exceeds max_summary_chars=%d even after structured truncation", maxChars) + } + return shrunk, nil +} + +// normalizeSummaryDocument 规整 section bullet 的空白形式,减少无意义字符占用。 +func normalizeSummaryDocument(document parsedSummary) parsedSummary { + normalized := parsedSummary{ + Sections: make([]parsedSummarySection, 0, len(document.Sections)), + } + for _, section := range document.Sections { + next := parsedSummarySection{ + Name: section.Name, + Bullets: make([]string, 0, len(section.Bullets)), + } + for _, bullet := range section.Bullets { + text := collapseWhitespace(bullet) + if text == "" { + text = "none" + } + next.Bullets = append(next.Bullets, text) + } + if len(next.Bullets) == 0 { + next.Bullets = append(next.Bullets, "none") + } + normalized.Sections = append(normalized.Sections, next) + } + return normalized +} + +// keepFirstBulletPerSection 将每个 section 收敛为一条 bullet,控制摘要在超长时的体积。 +func keepFirstBulletPerSection(document parsedSummary) parsedSummary { + condensed := parsedSummary{ + Sections: make([]parsedSummarySection, 0, len(document.Sections)), + } + for _, section := range document.Sections { + bullet := "none" + if len(section.Bullets) > 0 { + bullet = section.Bullets[0] + } + condensed.Sections = append(condensed.Sections, parsedSummarySection{ + Name: section.Name, + Bullets: []string{bullet}, + }) + } + return condensed +} + +// fitSummaryDocument 逐步收缩最长 bullet,直到摘要落入预算或确认预算不可满足。 +func fitSummaryDocument(document parsedSummary, maxChars int) (parsedSummary, bool) { + fitted := cloneParsedSummary(document) + if runeCount(renderSummaryDocument(withAllBullets(fitted, "none"))) > maxChars { + return parsedSummary{}, false + } + + for runeCount(renderSummaryDocument(fitted)) > maxChars { + sectionIndex := longestBulletSection(fitted) + if sectionIndex < 0 { + break + } + + current := fitted.Sections[sectionIndex].Bullets[0] + if runeCount(current) <= len([]rune("none")) { + fitted.Sections[sectionIndex].Bullets[0] = "none" + if runeCount(renderSummaryDocument(fitted)) > maxChars { + break + } + continue + } + + overflow := runeCount(renderSummaryDocument(fitted)) - maxChars + currentRunes := runeCount(current) + target := currentRunes - overflow + minimum := len([]rune("none")) + if target < minimum { + target = minimum + } + fitted.Sections[sectionIndex].Bullets[0] = truncateSummaryBullet(current, target) + } + + return fitted, runeCount(renderSummaryDocument(fitted)) <= maxChars +} + +// withAllBullets 生成所有 section 都使用同一条 bullet 的文档,用于评估最小合法体积。 +func withAllBullets(document parsedSummary, bullet string) parsedSummary { + out := cloneParsedSummary(document) + for index := range out.Sections { + out.Sections[index].Bullets = []string{bullet} + } + return out +} + +// cloneParsedSummary 深拷贝解析后的摘要结构,避免收缩阶段共享底层切片。 +func cloneParsedSummary(document parsedSummary) parsedSummary { + out := parsedSummary{ + Sections: make([]parsedSummarySection, 0, len(document.Sections)), + } + for _, section := range document.Sections { + out.Sections = append(out.Sections, parsedSummarySection{ + Name: section.Name, + Bullets: append([]string(nil), section.Bullets...), + }) + } + return out +} + +// longestBulletSection 返回当前最长首条 bullet 所在 section 下标。 +func longestBulletSection(document parsedSummary) int { + index := -1 + longest := 0 + for sectionIndex, section := range document.Sections { + if len(section.Bullets) == 0 { + continue + } + length := runeCount(section.Bullets[0]) + if length > longest { + index = sectionIndex + longest = length + } + } + return index +} + +// truncateSummaryBullet 截断单条 bullet,并在预算允许时保留省略号。 +func truncateSummaryBullet(input string, max int) string { + input = collapseWhitespace(input) + if max <= 0 { + return "" + } + if runeCount(input) <= max { + return input + } + if max <= len([]rune("none")) { + return "none" + } + if max <= 3 { + truncated, _ := truncateRunes(input, max) + return truncated + } + + truncated, _ := truncateRunes(input, max-3) + return strings.TrimSpace(truncated) + "..." +} + +// renderSummaryDocument 将解析后的摘要结构还原为持久化使用的协议文本。 +func renderSummaryDocument(document parsedSummary) string { + lines := []string{internalcompact.SummaryMarker} + for index, section := range document.Sections { + if index > 0 { + lines = append(lines, "") + } + lines = append(lines, section.Name+":") + for _, bullet := range section.Bullets { + lines = append(lines, "- "+strings.TrimSpace(bullet)) + } } - return nil + return normalizeSummary(strings.Join(lines, "\n")) +} + +// collapseWhitespace 折叠连续空白,减少摘要内容中的无效体积。 +func collapseWhitespace(input string) string { + fields := strings.Fields(strings.TrimSpace(input)) + if len(fields) == 0 { + return "" + } + return strings.Join(fields, " ") } // nextNonEmptyLine 返回从给定位置开始的下一条非空白行下标。 @@ -111,3 +314,21 @@ func isSummarySectionHeader(line string) bool { } return false } + +// truncateRunes 按 rune 数量截断字符串,避免破坏多字节字符。 +func truncateRunes(input string, max int) (string, bool) { + if max <= 0 { + return "", input != "" + } + if runeCount(input) <= max { + return input, false + } + + runes := []rune(input) + return string(runes[:max]), true +} + +// runeCount 统一按 rune 计算长度,确保摘要预算对中文等多字节文本稳定生效。 +func runeCount(input string) int { + return len([]rune(input)) +} diff --git a/internal/context/compact/summary_validator_test.go b/internal/context/compact/summary_validator_test.go index 73f6b621..b2bf0fd7 100644 --- a/internal/context/compact/summary_validator_test.go +++ b/internal/context/compact/summary_validator_test.go @@ -26,7 +26,7 @@ func TestCompactSummaryValidatorValidateRejectsBrokenStructure(t *testing.T) { } } -func TestCompactSummaryValidatorValidateTrimsBeforeCheckingStructure(t *testing.T) { +func TestCompactSummaryValidatorValidateNormalizesWhitespace(t *testing.T) { t.Parallel() validator := compactSummaryValidator{} @@ -39,3 +39,35 @@ func TestCompactSummaryValidatorValidateTrimsBeforeCheckingStructure(t *testing. t.Fatalf("expected normalized summary, got %q", got) } } + +func TestCompactSummaryValidatorValidateShrinksLongSummaryWithinBudget(t *testing.T) { + t.Parallel() + + lines := []string{"[compact_summary]"} + for _, section := range summarySections { + lines = append(lines, "", section+":", "- "+strings.Repeat(section+" detail ", 20), "- extra context") + } + summary := strings.Join(lines, "\n") + + got, err := (compactSummaryValidator{}).Validate(summary, 220) + if err != nil { + t.Fatalf("expected structured shrink to succeed, got %v", err) + } + if len([]rune(got)) > 220 { + t.Fatalf("expected shrunk summary within budget, got %d runes", len([]rune(got))) + } + for _, section := range summarySections { + if !strings.Contains(got, section+":") { + t.Fatalf("expected section %q after shrink, got %q", section, got) + } + } +} + +func TestCompactSummaryValidatorValidateFailsWhenBudgetCannotFitStructure(t *testing.T) { + t.Parallel() + + _, err := (compactSummaryValidator{}).Validate(validSemanticSummary(), 40) + if err == nil || !strings.Contains(err.Error(), "max_summary_chars") { + t.Fatalf("expected impossible budget error, got %v", err) + } +} diff --git a/internal/context/compact_prompt.go b/internal/context/compact_prompt.go index 8b80e7ef..d79e56f4 100644 --- a/internal/context/compact_prompt.go +++ b/internal/context/compact_prompt.go @@ -1,7 +1,6 @@ package context import ( - "encoding/json" "fmt" "strings" @@ -87,14 +86,70 @@ func buildCompactSummarySystemPrompt() string { return builder.String() } +// renderCompactPromptMessages 将消息渲染为紧凑的 transcript 视图,减少冗余 JSON 噪音。 func renderCompactPromptMessages(messages []provider.Message) string { if len(messages) == 0 { return "[]" } - payload, err := json.MarshalIndent(messages, "", " ") - if err != nil { - return "[]" + var builder strings.Builder + for index, message := range messages { + if index > 0 { + builder.WriteString("\n\n") + } + builder.WriteString(fmt.Sprintf("[message %d] role=%s", index, strings.TrimSpace(message.Role))) + if message.ToolCallID != "" { + builder.WriteString(fmt.Sprintf(" tool_call_id=%s", strings.TrimSpace(message.ToolCallID))) + } + if message.IsError { + builder.WriteString(" is_error=true") + } + + for _, call := range message.ToolCalls { + builder.WriteString("\n") + builder.WriteString(renderCompactPromptToolCall(call)) + } + + content := strings.TrimSpace(message.Content) + if content != "" { + builder.WriteString("\ncontent:") + builder.WriteString(renderCompactPromptContent(content)) + } + } + return builder.String() +} + +// renderCompactPromptToolCall 以单行形式渲染工具调用元信息,压缩摘要输入体积。 +func renderCompactPromptToolCall(call provider.ToolCall) string { + line := fmt.Sprintf( + "tool_call id=%s name=%s arguments=%s", + strings.TrimSpace(call.ID), + strings.TrimSpace(call.Name), + compactPromptInlineText(call.Arguments), + ) + return strings.TrimSpace(line) +} + +// renderCompactPromptContent 按缩进块渲染消息正文,兼顾可读性与多行内容边界。 +func renderCompactPromptContent(content string) string { + lines := strings.Split(content, "\n") + if len(lines) == 1 { + return " " + lines[0] + } + + var builder strings.Builder + for _, line := range lines { + builder.WriteString("\n ") + builder.WriteString(line) + } + return builder.String() +} + +// compactPromptInlineText 将多行文本折叠为单行,避免工具参数放大摘要 prompt。 +func compactPromptInlineText(input string) string { + fields := strings.Fields(strings.TrimSpace(input)) + if len(fields) == 0 { + return "{}" } - return string(payload) + return strings.Join(fields, " ") } diff --git a/internal/context/compact_prompt_test.go b/internal/context/compact_prompt_test.go index c6946349..5d38c006 100644 --- a/internal/context/compact_prompt_test.go +++ b/internal/context/compact_prompt_test.go @@ -18,7 +18,16 @@ func TestBuildCompactPromptIncludesFixedInstructionsAndBoundaries(t *testing.T) ArchivedMessageCount: 3, MaxSummaryChars: 1200, ArchivedMessages: []provider.Message{ - {Role: provider.RoleUser, Content: "legacy request"}, + { + Role: provider.RoleUser, + Content: "legacy request\nwith details", + }, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{\n \"path\": \"a.txt\"\n}"}, + }, + }, }, RetainedMessages: []provider.Message{ {Role: provider.RoleAssistant, Content: "recent answer"}, @@ -48,8 +57,17 @@ func TestBuildCompactPromptIncludesFixedInstructionsAndBoundaries(t *testing.T) if !strings.Contains(prompt.UserPrompt, "") { t.Fatalf("expected retained material boundary, got %q", prompt.UserPrompt) } - if !strings.Contains(prompt.UserPrompt, "\"role\": \"user\"") { - t.Fatalf("expected archived messages rendered as JSON, got %q", prompt.UserPrompt) + if strings.Contains(prompt.UserPrompt, "\"role\": \"user\"") { + t.Fatalf("expected compact transcript rendering instead of pretty JSON, got %q", prompt.UserPrompt) + } + if !strings.Contains(prompt.UserPrompt, "[message 0] role=user") { + t.Fatalf("expected compact transcript header, got %q", prompt.UserPrompt) + } + if !strings.Contains(prompt.UserPrompt, "tool_call id=call-1 name=filesystem_read_file") { + t.Fatalf("expected tool call metadata in compact transcript, got %q", prompt.UserPrompt) + } + if !strings.Contains(prompt.UserPrompt, "content:\n legacy request") { + t.Fatalf("expected multiline content block in compact transcript, got %q", prompt.UserPrompt) } if !strings.Contains(prompt.UserPrompt, "target_max_summary_chars: 1200") { t.Fatalf("expected target max chars in user prompt, got %q", prompt.UserPrompt) diff --git a/internal/context/internalcompact/messages.go b/internal/context/internalcompact/messages.go new file mode 100644 index 00000000..a81e891b --- /dev/null +++ b/internal/context/internalcompact/messages.go @@ -0,0 +1,93 @@ +package internalcompact + +import ( + "strings" + + "neo-code/internal/provider" +) + +// MessageSpan 描述一段不可拆分的消息区间,并携带是否需要保护的尾部语义。 +type MessageSpan struct { + Start int + End int + MessageCount int + Protected bool +} + +// BuildMessageSpans 按工具调用原子块构建消息分段,并保护最后一条明确用户指令所在分段。 +func BuildMessageSpans(messages []provider.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 { + end++ + } + } + + spans = append(spans, MessageSpan{ + Start: start, + End: end, + MessageCount: end - start, + }) + i = end + } + + if lastUserIndex := lastExplicitUserMessageIndex(messages); lastUserIndex >= 0 { + markSpanProtected(spans, lastUserIndex) + } + return spans +} + +// ProtectedTailStart 返回必须原样保留的受保护尾部起点。 +func ProtectedTailStart(spans []MessageSpan) (int, bool) { + for _, span := range spans { + if span.Protected { + return span.Start, true + } + } + return 0, false +} + +// RetainedStartForKeepRecentMessages 计算按消息数保留最近上下文时的起始位置,并尊重受保护尾部。 +func RetainedStartForKeepRecentMessages(spans []MessageSpan, keepMessages int) int { + if len(spans) == 0 { + return 0 + } + + retainedStart := spans[0].Start + retainedMessages := 0 + for index := len(spans) - 1; index >= 0; index-- { + retainedMessages += spans[index].MessageCount + retainedStart = spans[index].Start + if retainedMessages >= keepMessages { + break + } + } + + if protectedStart, ok := ProtectedTailStart(spans); ok && protectedStart < retainedStart { + retainedStart = protectedStart + } + return retainedStart +} + +// lastExplicitUserMessageIndex 返回最后一条非空用户消息的位置,用于保护最近明确指令。 +func lastExplicitUserMessageIndex(messages []provider.Message) int { + for index := len(messages) - 1; index >= 0; index-- { + if messages[index].Role == provider.RoleUser && strings.TrimSpace(messages[index].Content) != "" { + return index + } + } + return -1 +} + +// markSpanProtected 将包含目标消息的分段标记为受保护分段。 +func markSpanProtected(spans []MessageSpan, messageIndex int) { + for index := range spans { + if messageIndex >= spans[index].Start && messageIndex < spans[index].End { + spans[index].Protected = true + return + } + } +} diff --git a/internal/context/internalcompact/messages_test.go b/internal/context/internalcompact/messages_test.go new file mode 100644 index 00000000..09c73181 --- /dev/null +++ b/internal/context/internalcompact/messages_test.go @@ -0,0 +1,58 @@ +package internalcompact + +import ( + "testing" + + "neo-code/internal/provider" +) + +func TestBuildMessageSpansPreservesToolBlocksAndProtectedTail(t *testing.T) { + t.Parallel() + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "old"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.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"}, + } + + spans := BuildMessageSpans(messages) + if len(spans) != 5 { + t.Fatalf("expected 5 spans, got %+v", spans) + } + if spans[1].Start != 1 || spans[1].End != 3 || spans[1].MessageCount != 2 { + t.Fatalf("expected assistant/tool messages to share one span, got %+v", spans[1]) + } + + protectedStart, ok := ProtectedTailStart(spans) + if !ok { + t.Fatalf("expected protected tail") + } + if protectedStart != 4 { + t.Fatalf("expected latest explicit user instruction span protected, got %d", protectedStart) + } +} + +func TestRetainedStartForKeepRecentMessagesHonorsProtectedTail(t *testing.T) { + t.Parallel() + + spans := []MessageSpan{ + {Start: 0, End: 1, MessageCount: 1}, + {Start: 1, End: 2, MessageCount: 1}, + {Start: 2, End: 3, MessageCount: 1, Protected: true}, + {Start: 3, End: 4, MessageCount: 1}, + {Start: 4, End: 5, MessageCount: 1}, + } + + start := RetainedStartForKeepRecentMessages(spans, 2) + if start != 2 { + t.Fatalf("expected protected tail start 2, got %d", start) + } +} diff --git a/internal/context/source_rules.go b/internal/context/source_rules.go index 9363323b..133ef53c 100644 --- a/internal/context/source_rules.go +++ b/internal/context/source_rules.go @@ -25,6 +25,139 @@ type ruleDocument struct { type ruleFileFinder func(string) (string, error) +// Sections 按当前工作目录向上发现 AGENTS.md,并优先复用未失效的缓存结果。 +func (s *projectRulesSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) { + rules, err := s.loadCachedProjectRules(ctx, input.Metadata.Workdir) + if err != nil { + return nil, err + } + + section := renderProjectRulesSection(rules) + if renderPromptSection(section) == "" { + return nil, nil + } + return []promptSection{section}, nil +} + +// loadCachedProjectRules 加载项目规则,并在路径和 mtime 未变化时复用缓存内容。 +func (s *projectRulesSource) loadCachedProjectRules(ctx context.Context, workdir string) ([]ruleDocument, error) { + key := normalizeRuleCacheKey(workdir) + if key == "" { + return nil, nil + } + + s.mu.Lock() + entry, ok := s.cache[key] + s.mu.Unlock() + + if ok { + valid, err := s.isRuleCacheEntryValid(entry) + if err != nil { + return nil, err + } + if valid { + return cloneRuleDocuments(entry.documents), nil + } + } + + documents, err := s.ruleLoader()(ctx, workdir) + if err != nil { + return nil, err + } + + snapshots, err := snapshotRuleFiles(ruleDocumentPaths(documents), s.ruleStatFile()) + if err != nil { + return nil, err + } + + s.mu.Lock() + if s.cache == nil { + s.cache = make(map[string]cachedRuleDocuments) + } + s.cache[key] = cachedRuleDocuments{ + documents: cloneRuleDocuments(documents), + snapshots: snapshots, + } + s.mu.Unlock() + + return documents, nil +} + +// isRuleCacheEntryValid 根据规则文件路径与 mtime 判断缓存是否仍可复用。 +func (s *projectRulesSource) isRuleCacheEntryValid(entry cachedRuleDocuments) (bool, error) { + statFile := s.ruleStatFile() + for _, snapshot := range entry.snapshots { + info, err := statFile(snapshot.Path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("context: stat %s: %w", snapshot.Path, err) + } + if info.Size() != snapshot.Size || !info.ModTime().Equal(snapshot.ModTime) { + return false, nil + } + } + return true, nil +} + +// ruleLoader 返回 project rules 的实际加载函数,便于测试注入。 +func (s *projectRulesSource) ruleLoader() projectRulesLoader { + if s != nil && s.loadRules != nil { + return s.loadRules + } + return loadProjectRules +} + +// ruleStatFile 返回规则文件 stat 函数,便于测试控制缓存失效条件。 +func (s *projectRulesSource) ruleStatFile() ruleFileStat { + if s != nil && s.statFile != nil { + return s.statFile + } + return os.Stat +} + +// normalizeRuleCacheKey 统一清洗 workdir,避免缓存键受路径噪音影响。 +func normalizeRuleCacheKey(workdir string) string { + workdir = strings.TrimSpace(workdir) + if workdir == "" { + return "" + } + return filepath.Clean(workdir) +} + +// cloneRuleDocuments 深拷贝规则文档切片,避免缓存与调用方共享底层数据。 +func cloneRuleDocuments(documents []ruleDocument) []ruleDocument { + return append([]ruleDocument(nil), documents...) +} + +// ruleDocumentPaths 提取规则文档路径,用于缓存签名计算。 +func ruleDocumentPaths(documents []ruleDocument) []string { + paths := make([]string, 0, len(documents)) + for _, document := range documents { + paths = append(paths, document.Path) + } + return paths +} + +// snapshotRuleFiles 采集规则文件的路径、mtime 与 size,供缓存命中判断使用。 +func snapshotRuleFiles(paths []string, statFile ruleFileStat) ([]ruleFileSnapshot, error) { + snapshots := make([]ruleFileSnapshot, 0, len(paths)) + for _, path := range paths { + info, err := statFile(path) + if err != nil { + return nil, fmt.Errorf("context: stat %s: %w", path, err) + } + snapshots = append(snapshots, ruleFileSnapshot{ + Path: path, + ModTime: info.ModTime(), + Size: info.Size(), + }) + } + return snapshots, nil +} + +// loadProjectRules 发现并读取当前工作目录可见的规则文件。 func loadProjectRules(ctx context.Context, workdir string) ([]ruleDocument, error) { paths, err := discoverRuleFiles(ctx, workdir) if err != nil { @@ -34,6 +167,7 @@ func loadProjectRules(ctx context.Context, workdir string) ([]ruleDocument, erro return loadRuleDocuments(ctx, paths, os.ReadFile) } +// loadRuleDocuments 按顺序读取规则文件并应用单文件裁剪预算。 func loadRuleDocuments(ctx context.Context, paths []string, readFile func(string) ([]byte, error)) ([]ruleDocument, error) { documents := make([]ruleDocument, 0, len(paths)) for _, path := range paths { @@ -57,10 +191,12 @@ func loadRuleDocuments(ctx context.Context, paths []string, readFile func(string return documents, nil } +// discoverRuleFiles 自底向上发现当前工作目录可见的 AGENTS.md 文件。 func discoverRuleFiles(ctx context.Context, workdir string) ([]string, error) { return discoverRuleFilesWithFinder(ctx, workdir, findExactRuleFile) } +// discoverRuleFilesWithFinder 允许注入 finder 以便测试不同的规则发现行为。 func discoverRuleFilesWithFinder(ctx context.Context, workdir string, finder ruleFileFinder) ([]string, error) { workdir = strings.TrimSpace(workdir) if workdir == "" { @@ -100,6 +236,7 @@ func discoverRuleFilesWithFinder(ctx context.Context, workdir string, finder rul return paths, nil } +// findExactRuleFile 只匹配大小写完全一致的 AGENTS.md,避免误读同名变体。 func findExactRuleFile(dir string) (string, error) { entries, err := os.ReadDir(dir) if err != nil { @@ -121,6 +258,7 @@ func findExactRuleFile(dir string) (string, error) { return "", nil } +// renderProjectRulesSection 将规则文档渲染为统一 prompt section,并应用总预算裁剪。 func renderProjectRulesSection(documents []ruleDocument) promptSection { if len(documents) == 0 { return promptSection{} @@ -167,6 +305,7 @@ func renderProjectRulesSection(documents []ruleDocument) promptSection { } } +// renderRuleDocumentChunk 渲染单个规则文档块。 func renderRuleDocumentChunk(document ruleDocument) string { var builder strings.Builder builder.WriteString("\n### ") @@ -184,6 +323,7 @@ func renderRuleDocumentChunk(document ruleDocument) string { return builder.String() } +// renderRuleDocumentChunkWithinBudget 在剩余预算内渲染单个规则文档块。 func renderRuleDocumentChunkWithinBudget(document ruleDocument, budget int) string { if budget <= 0 { return "" @@ -223,6 +363,7 @@ func renderRuleDocumentChunkWithinBudget(document ruleDocument, budget int) stri return header + body.String() } +// truncateRunes 按 rune 数量裁剪文本,避免截断多字节字符。 func truncateRunes(input string, max int) (string, bool) { if max <= 0 { return "", input != "" @@ -235,6 +376,7 @@ func truncateRunes(input string, max int) (string, bool) { return string(runes[:max]), true } +// runeCount 统一按 rune 数量统计文本体积。 func runeCount(input string) int { return utf8.RuneCountInString(input) } diff --git a/internal/context/source_rules_test.go b/internal/context/source_rules_test.go index 45794725..b265604e 100644 --- a/internal/context/source_rules_test.go +++ b/internal/context/source_rules_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestLoadProjectRulesOrdersGlobalToLocal(t *testing.T) { @@ -133,6 +134,50 @@ func TestDiscoverRuleFilesReturnsDirectoryReadError(t *testing.T) { } } +func TestProjectRulesSourceCachesAndInvalidatesByMTime(t *testing.T) { + t.Parallel() + + root := t.TempDir() + rulesPath := filepath.Join(root, projectRuleFileName) + if err := os.WriteFile(rulesPath, []byte("version-1"), 0o644); err != nil { + t.Fatalf("write rules: %v", err) + } + + loadCalls := 0 + source := &projectRulesSource{ + loadRules: func(ctx context.Context, workdir string) ([]ruleDocument, error) { + loadCalls++ + return loadProjectRules(ctx, workdir) + }, + statFile: os.Stat, + } + + if _, err := source.Sections(context.Background(), BuildInput{Metadata: testMetadata(root)}); err != nil { + t.Fatalf("first Sections() error = %v", err) + } + if _, err := source.Sections(context.Background(), BuildInput{Metadata: testMetadata(root)}); err != nil { + t.Fatalf("second Sections() error = %v", err) + } + if loadCalls != 1 { + t.Fatalf("expected cached rules on second call, got loadCalls=%d", loadCalls) + } + + future := time.Now().Add(2 * time.Second) + if err := os.WriteFile(rulesPath, []byte("version-2 with more content"), 0o644); err != nil { + t.Fatalf("rewrite rules: %v", err) + } + if err := os.Chtimes(rulesPath, future, future); err != nil { + t.Fatalf("chtimes rules: %v", err) + } + + if _, err := source.Sections(context.Background(), BuildInput{Metadata: testMetadata(root)}); err != nil { + t.Fatalf("third Sections() error = %v", err) + } + if loadCalls != 2 { + t.Fatalf("expected cache invalidation after mtime change, got loadCalls=%d", loadCalls) + } +} + func TestRenderProjectRulesSectionTruncatesSingleFileAndTotalBudget(t *testing.T) { t.Parallel() diff --git a/internal/context/source_system.go b/internal/context/source_system.go index 6ee12e0d..bd46d1d7 100644 --- a/internal/context/source_system.go +++ b/internal/context/source_system.go @@ -10,6 +10,7 @@ import ( type gitCommandRunner func(ctx context.Context, workdir string, args ...string) (string, error) +// collectSystemState 汇总运行时上下文,并通过一次 git status 调用获取分支与脏状态。 func collectSystemState(ctx context.Context, metadata Metadata, runner gitCommandRunner) (SystemState, error) { state := SystemState{ Workdir: strings.TrimSpace(metadata.Workdir), @@ -25,27 +26,58 @@ func collectSystemState(ctx context.Context, metadata Metadata, runner gitComman return state, nil } - branch, err := runner(ctx, state.Workdir, "rev-parse", "--abbrev-ref", "HEAD") + statusOutput, err := runner(ctx, state.Workdir, "status", "--short", "--branch") if err != nil { if isContextError(err) { return state, err } return state, nil } - dirty, err := runner(ctx, state.Workdir, "status", "--porcelain") - if err != nil { - if isContextError(err) { - return state, err + + state.Git = parseGitStatusSummary(statusOutput) + return state, nil +} + +// parseGitStatusSummary 解析 git status --short --branch 输出中的分支与脏状态。 +func parseGitStatusSummary(output string) GitState { + lines := strings.Split(strings.ReplaceAll(output, "\r\n", "\n"), "\n") + trimmed := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + trimmed = append(trimmed, line) } - return state, nil + } + if len(trimmed) == 0 { + return GitState{} } - state.Git = GitState{ - Available: true, - Branch: strings.TrimSpace(branch), - Dirty: strings.TrimSpace(dirty) != "", + state := GitState{Available: true} + firstLine := trimmed[0] + if strings.HasPrefix(firstLine, "## ") { + state.Branch = parseGitBranchLine(strings.TrimPrefix(firstLine, "## ")) + trimmed = trimmed[1:] + } + state.Dirty = len(trimmed) > 0 + return state +} + +// parseGitBranchLine 从 git branch 摘要行中提取用户可读的分支名称。 +func parseGitBranchLine(line string) string { + line = strings.TrimSpace(line) + switch { + case line == "": + return "" + case strings.HasPrefix(line, "No commits yet on "): + return strings.TrimSpace(strings.TrimPrefix(line, "No commits yet on ")) + case strings.HasPrefix(line, "HEAD "): + return "detached" + default: + if index := strings.Index(line, "..."); index >= 0 { + line = line[:index] + } + return strings.TrimSpace(line) } - return state, nil } func renderSystemStateSection(state SystemState) promptSection { diff --git a/internal/context/source_system_test.go b/internal/context/source_system_test.go index 19c1b39e..af9ebcbd 100644 --- a/internal/context/source_system_test.go +++ b/internal/context/source_system_test.go @@ -27,18 +27,16 @@ func TestCollectSystemStateHandlesGitUnavailable(t *testing.T) { } } -func TestCollectSystemStateIncludesGitSummary(t *testing.T) { +func TestCollectSystemStateIncludesGitSummaryFromSingleCall(t *testing.T) { t.Parallel() + callCount := 0 runner := func(ctx context.Context, workdir string, args ...string) (string, error) { - switch strings.Join(args, " ") { - case "rev-parse --abbrev-ref HEAD": - return "feature/context\n", nil - case "status --porcelain": - return " M internal/context/builder.go\n", nil - default: + callCount++ + if strings.Join(args, " ") != "status --short --branch" { return "", errors.New("unexpected git command") } + return "## feature/context...origin/feature/context\n M internal/context/builder.go\n", nil } state, err := collectSystemState(context.Background(), testMetadata("/workspace"), runner) @@ -46,6 +44,9 @@ func TestCollectSystemStateIncludesGitSummary(t *testing.T) { t.Fatalf("collectSystemState() error = %v", err) } + if callCount != 1 { + t.Fatalf("expected a single git call, got %d", callCount) + } if !state.Git.Available { t.Fatalf("expected git to be available") } diff --git a/internal/context/sources.go b/internal/context/sources.go index 1ecdbb7b..7bd868f8 100644 --- a/internal/context/sources.go +++ b/internal/context/sources.go @@ -1,6 +1,11 @@ package context -import "context" +import ( + "context" + "os" + "sync" + "time" +) // promptSectionSource 约束单个 prompt section 来源的最小能力,避免 Builder 持有具体细节。 type promptSectionSource interface { @@ -18,21 +23,26 @@ func (corePromptSource) Sections(ctx context.Context, input BuildInput) ([]promp return append([]promptSection(nil), defaultSystemPromptSections()...), nil } -// projectRulesSource 只负责发现并渲染项目规则文件。 -type projectRulesSource struct{} +type projectRulesLoader func(ctx context.Context, workdir string) ([]ruleDocument, error) +type ruleFileStat func(path string) (os.FileInfo, error) -// Sections 按当前工作目录向上发现 AGENTS.md,并渲染为统一 section。 -func (projectRulesSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) { - rules, err := loadProjectRules(ctx, input.Metadata.Workdir) - if err != nil { - return nil, err - } +type ruleFileSnapshot struct { + Path string + ModTime time.Time + Size int64 +} - section := renderProjectRulesSection(rules) - if renderPromptSection(section) == "" { - return nil, nil - } - return []promptSection{section}, nil +type cachedRuleDocuments struct { + documents []ruleDocument + snapshots []ruleFileSnapshot +} + +// projectRulesSource 负责发现、缓存并渲染项目规则文件。 +type projectRulesSource struct { + mu sync.Mutex + cache map[string]cachedRuleDocuments + loadRules projectRulesLoader + statFile ruleFileStat } // systemStateSource 只负责收集并渲染运行时系统摘要。 @@ -41,7 +51,7 @@ type systemStateSource struct { } // Sections 汇总 workdir、shell、provider、model 与 git 摘要信息。 -func (s systemStateSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) { +func (s *systemStateSource) Sections(ctx context.Context, input BuildInput) ([]promptSection, error) { systemState, err := collectSystemState(ctx, input.Metadata, s.gitRunner) if err != nil { return nil, err diff --git a/internal/context/sources_test.go b/internal/context/sources_test.go index c977cca6..d78b3f1f 100644 --- a/internal/context/sources_test.go +++ b/internal/context/sources_test.go @@ -33,7 +33,7 @@ func TestCorePromptSourceSectionsReturnsClone(t *testing.T) { func TestProjectRulesSourceSectionsSkipsWhenNoRulesExist(t *testing.T) { t.Parallel() - sections, err := (projectRulesSource{}).Sections(context.Background(), BuildInput{ + sections, err := (&projectRulesSource{}).Sections(context.Background(), BuildInput{ Metadata: Metadata{Workdir: t.TempDir()}, }) if err != nil { @@ -52,7 +52,7 @@ func TestProjectRulesSourceSectionsRendersRules(t *testing.T) { t.Fatalf("write AGENTS.md: %v", err) } - sections, err := (projectRulesSource{}).Sections(context.Background(), BuildInput{ + sections, err := (&projectRulesSource{}).Sections(context.Background(), BuildInput{ Metadata: Metadata{Workdir: root}, }) if err != nil { diff --git a/internal/context/trim.go b/internal/context/trim.go index 155703d0..91d04e8f 100644 --- a/internal/context/trim.go +++ b/internal/context/trim.go @@ -1,37 +1,22 @@ package context -import "neo-code/internal/provider" +import ( + "neo-code/internal/context/internalcompact" + "neo-code/internal/provider" +) const maxRetainedMessageSpans = 10 +// trimMessages 按消息分段裁剪上下文,并始终保护最近一条明确用户指令所在尾部。 func trimMessages(messages []provider.Message) []provider.Message { - if len(messages) <= maxRetainedMessageSpans { - return append([]provider.Message(nil), messages...) - } - - type span struct { - start int - end int - } - - spans := make([]span, 0, len(messages)) - for i := 0; i < len(messages); { - start := i - i++ - - if messages[start].Role == provider.RoleAssistant && len(messages[start].ToolCalls) > 0 { - for i < len(messages) && messages[i].Role == provider.RoleTool { - i++ - } - } - - spans = append(spans, span{start: start, end: i}) - } - + spans := internalcompact.BuildMessageSpans(messages) if len(spans) <= maxRetainedMessageSpans { return append([]provider.Message(nil), messages...) } - start := spans[len(spans)-maxRetainedMessageSpans].start + start := spans[len(spans)-maxRetainedMessageSpans].Start + if protectedStart, ok := internalcompact.ProtectedTailStart(spans); ok && protectedStart < start { + start = protectedStart + } return append([]provider.Message(nil), messages[start:]...) } diff --git a/internal/runtime/compact_generator_test.go b/internal/runtime/compact_generator_test.go index b7a1d525..def57458 100644 --- a/internal/runtime/compact_generator_test.go +++ b/internal/runtime/compact_generator_test.go @@ -50,7 +50,12 @@ func TestCompactSummaryGeneratorBuildsProviderRequestWithoutTools(t *testing.T) Mode: contextcompact.ModeManual, ArchivedMessages: []provider.Message{ {Role: provider.RoleUser, Content: "legacy request"}, - {Role: provider.RoleAssistant, Content: "legacy answer"}, + { + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}, + }, + }, }, RetainedMessages: []provider.Message{ {Role: provider.RoleAssistant, Content: "recent answer"}, @@ -90,8 +95,14 @@ func TestCompactSummaryGeneratorBuildsProviderRequestWithoutTools(t *testing.T) if !strings.Contains(req.Messages[0].Content, "") { t.Fatalf("expected archived material boundary, got %q", req.Messages[0].Content) } - if !strings.Contains(req.Messages[0].Content, "\"role\": \"user\"") { - t.Fatalf("expected archived messages rendered as JSON, got %q", req.Messages[0].Content) + if strings.Contains(req.Messages[0].Content, "\"role\": \"user\"") { + t.Fatalf("expected transcript-style compact prompt instead of pretty JSON, got %q", req.Messages[0].Content) + } + if !strings.Contains(req.Messages[0].Content, "[message 0] role=user") { + t.Fatalf("expected transcript-style user message header, got %q", req.Messages[0].Content) + } + if !strings.Contains(req.Messages[0].Content, "tool_call id=call-1 name=filesystem_read_file") { + t.Fatalf("expected tool call metadata in compact prompt, got %q", req.Messages[0].Content) } } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index e67038d4..0214c574 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -525,10 +525,16 @@ func TestServiceRunFailurePreservesExistingSessionProviderAndModel(t *testing.T) t.Parallel() manager := newRuntimeConfigManager(t) - setRuntimeProviderEnv(t, config.GeminiDefaultAPIKeyEnv, "gemini-key") + geminiEnv := runtimeTestAPIKeyEnv(t) + "_GEMINI" + setRuntimeProviderEnv(t, geminiEnv, "gemini-key") if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.SelectedProvider = config.GeminiName cfg.CurrentModel = "gemini-current-model" + for index := range cfg.Providers { + if config.NormalizeProviderName(cfg.Providers[index].Name) == config.NormalizeProviderName(config.GeminiName) { + cfg.Providers[index].APIKeyEnv = geminiEnv + } + } return nil }); err != nil { t.Fatalf("update config: %v", err) From 7491ff1c8ff8d28f4827a322f3a23439c17f8082 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 5 Apr 2026 10:25:38 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E9=94=99=E8=AF=AF=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/builder.go | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/internal/context/builder.go b/internal/context/builder.go index b01f70ed..ff1487ea 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -6,8 +6,6 @@ import "context" type DefaultBuilder struct { promptSources []promptSectionSource trimPolicy messageTrimPolicy - promptSources []promptSectionSource - trimPolicy messageTrimPolicy } // NewBuilder returns the default context builder implementation. @@ -38,19 +36,6 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu sections = append(sections, sourceSections...) } - trimPolicy := b.trimPolicy - if trimPolicy == nil { - trimPolicy = spanMessageTrimPolicy{} - } - sections := make([]promptSection, 0, len(b.promptSources)+1) - for _, source := range b.promptSources { - sourceSections, err := source.Sections(ctx, input) - if err != nil { - return BuildResult{}, err - } - sections = append(sections, sourceSections...) - } - trimPolicy := b.trimPolicy if trimPolicy == nil { trimPolicy = spanMessageTrimPolicy{} @@ -59,6 +44,5 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu return BuildResult{ SystemPrompt: composeSystemPrompt(sections...), Messages: trimPolicy.Trim(input.Messages), - Messages: trimPolicy.Trim(input.Messages), }, nil } From 18dd5a1d6a6698db1a9e927f22a9acf627bb3460 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 5 Apr 2026 10:48:10 +0800 Subject: [PATCH 5/7] =?UTF-8?q?test=EF=BC=9A=E8=A1=A5=E9=BD=90=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/builder_test.go | 56 +++++++++ internal/context/compact/planner_test.go | 23 ++++ internal/context/compact/runner_test.go | 61 +++++++++ .../context/compact/transcript_store_test.go | 63 ++++++++++ internal/context/prompt_test.go | 16 +++ internal/context/source_rules_test.go | 118 ++++++++++++++++++ internal/context/source_system_test.go | 74 +++++++++++ internal/context/sources_test.go | 13 ++ 8 files changed, 424 insertions(+) diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 141e3737..42c002ba 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -12,6 +12,18 @@ import ( "neo-code/internal/provider" ) +type stubPromptSectionSource struct { + sections []promptSection + err error +} + +func (s stubPromptSectionSource) Sections(ctx stdcontext.Context, input BuildInput) ([]promptSection, error) { + if s.err != nil { + return nil, s.err + } + return append([]promptSection(nil), s.sections...), nil +} + func TestDefaultBuilderBuild(t *testing.T) { t.Parallel() @@ -94,6 +106,50 @@ func TestDefaultBuilderBuildComposesPromptSectionsInOrder(t *testing.T) { } } +func TestDefaultBuilderBuildUsesSpanTrimPolicyWhenTrimPolicyIsUnset(t *testing.T) { + t.Parallel() + + messages := make([]provider.Message, 0, maxRetainedMessageSpans+2) + for i := 0; i < maxRetainedMessageSpans+2; i++ { + messages = append(messages, provider.Message{ + Role: provider.RoleUser, + Content: fmt.Sprintf("u-%d", i), + }) + } + + builder := &DefaultBuilder{ + promptSources: []promptSectionSource{ + stubPromptSectionSource{sections: []promptSection{{title: "Stub", content: "body"}}}, + }, + } + + got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages}) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if len(got.Messages) != maxRetainedMessageSpans { + t.Fatalf("expected %d retained messages, got %d", maxRetainedMessageSpans, len(got.Messages)) + } + if got.Messages[0].Content != "u-2" { + t.Fatalf("expected oldest messages to be trimmed, got first message %+v", got.Messages[0]) + } +} + +func TestDefaultBuilderBuildReturnsPromptSourceError(t *testing.T) { + t.Parallel() + + builder := &DefaultBuilder{ + promptSources: []promptSectionSource{ + stubPromptSectionSource{err: fmt.Errorf("source failed")}, + }, + } + + _, err := builder.Build(stdcontext.Background(), BuildInput{}) + if err == nil || !strings.Contains(err.Error(), "source failed") { + t.Fatalf("expected source error, got %v", err) + } +} + func TestTrimMessagesPreservesToolPairs(t *testing.T) { t.Parallel() diff --git a/internal/context/compact/planner_test.go b/internal/context/compact/planner_test.go index 3a2a10e9..be243445 100644 --- a/internal/context/compact/planner_test.go +++ b/internal/context/compact/planner_test.go @@ -73,3 +73,26 @@ func TestCompactionPlannerRejectsUnsupportedStrategy(t *testing.T) { t.Fatalf("expected unsupported strategy error") } } + +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"}, + }, config.CompactConfig{ + ManualStrategy: "unsupported", + ManualKeepRecentMessages: 2, + }) + if err != nil { + t.Fatalf("Plan() error = %v", err) + } + if !plan.Applied { + t.Fatalf("expected reactive plan to keep recent messages") + } + if len(plan.Archived) != 2 || len(plan.Retained) != 2 { + t.Fatalf("unexpected reactive plan: %+v", plan) + } +} diff --git a/internal/context/compact/runner_test.go b/internal/context/compact/runner_test.go index 4a0e05a7..ccf92451 100644 --- a/internal/context/compact/runner_test.go +++ b/internal/context/compact/runner_test.go @@ -626,3 +626,64 @@ func TestManualCompactKeepRecentWithoutEnoughMessagesSkipsGenerator(t *testing.T t.Fatalf("expected generator not to run, got %d calls", len(generator.calls)) } } + +func TestManualCompactReturnsErrorWhenSummaryGeneratorIsMissing(t *testing.T) { + t.Parallel() + + runner := NewRunner(nil) + runner.userHomeDir = func() (string, error) { return t.TempDir(), nil } + + _, err := runner.Run(context.Background(), Input{ + 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"}, + }, + Config: config.CompactConfig{ + ManualStrategy: config.CompactManualStrategyFullReplace, + ManualKeepRecentMessages: 10, + MaxSummaryChars: 1200, + }, + }) + if err == nil || !strings.Contains(err.Error(), "summary generator is nil") { + t.Fatalf("expected missing generator error, got %v", err) + } +} + +func TestManualCompactDefaultsToKeepRecentStrategyWhenManualStrategyIsEmpty(t *testing.T) { + t.Parallel() + + generator := &stubSummaryGenerator{summary: validSemanticSummary()} + runner := NewRunner(generator) + runner.userHomeDir = func() (string, error) { return t.TempDir(), nil } + + result, err := runner.Run(context.Background(), Input{ + 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"}, + }, + Config: config.CompactConfig{ + ManualStrategy: "", + ManualKeepRecentMessages: 2, + MaxSummaryChars: 1200, + }, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if !result.Applied { + t.Fatalf("expected compact to apply with default keep_recent strategy") + } + if len(generator.calls) != 1 || generator.calls[0].Config.ManualStrategy != config.CompactManualStrategyKeepRecent { + t.Fatalf("expected keep_recent default strategy, got %+v", generator.calls) + } +} diff --git a/internal/context/compact/transcript_store_test.go b/internal/context/compact/transcript_store_test.go index 3c303d7d..b3616250 100644 --- a/internal/context/compact/transcript_store_test.go +++ b/internal/context/compact/transcript_store_test.go @@ -1,6 +1,7 @@ package compact import ( + "errors" "os" "path/filepath" "strings" @@ -58,3 +59,65 @@ func TestTranscriptFileModeForOS(t *testing.T) { t.Fatalf("expected non-windows mode 0600, got %#o", got) } } + +func TestTranscriptStoreSaveReturnsHomeDirectoryError(t *testing.T) { + t.Parallel() + + store := transcriptStore{ + userHomeDir: func() (string, error) { return "", errors.New("home boom") }, + } + + _, _, err := store.Save(nil, "session", "workspace") + if err == nil || !strings.Contains(err.Error(), "home boom") { + t.Fatalf("expected user home error, got %v", err) + } +} + +func TestTranscriptStoreSaveReturnsRandomTokenError(t *testing.T) { + t.Parallel() + + store := transcriptStore{ + now: func() time.Time { return time.Unix(1, 0) }, + userHomeDir: func() (string, error) { return t.TempDir(), nil }, + mkdirAll: func(path string, perm os.FileMode) error { return nil }, + randomToken: func() (string, error) { return "", errors.New("token boom") }, + } + + _, _, err := store.Save(nil, "session", "workspace") + if err == nil || !strings.Contains(err.Error(), "token boom") { + t.Fatalf("expected token generation error, got %v", err) + } +} + +func TestTranscriptStoreSaveRemovesTemporaryFileWhenRenameFails(t *testing.T) { + t.Parallel() + + home := t.TempDir() + written := "" + removed := "" + store := transcriptStore{ + now: func() time.Time { return time.Unix(1, 0) }, + userHomeDir: func() (string, error) { return home, nil }, + mkdirAll: func(path string, perm os.FileMode) error { return nil }, + randomToken: func() (string, error) { return "token1234", nil }, + writeFile: func(name string, data []byte, perm os.FileMode) error { + written = name + return nil + }, + rename: func(oldPath, newPath string) error { + return errors.New("rename boom") + }, + remove: func(path string) error { + removed = path + return nil + }, + } + + _, _, err := store.Save([]provider.Message{{Role: provider.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) + } + if written == "" || removed != written { + t.Fatalf("expected temp transcript cleanup, wrote %q removed %q", written, removed) + } +} diff --git a/internal/context/prompt_test.go b/internal/context/prompt_test.go index 73cd3d15..df9c3fd7 100644 --- a/internal/context/prompt_test.go +++ b/internal/context/prompt_test.go @@ -74,3 +74,19 @@ func TestRenderPromptSectionBranches(t *testing.T) { }) } } + +func TestComposeSystemPromptSkipsEmptySections(t *testing.T) { + t.Parallel() + + got := composeSystemPrompt( + promptSection{}, + promptSection{content: "plain"}, + promptSection{title: "Title Only"}, + promptSection{title: "Section", content: "body"}, + ) + + want := "plain\n\n## Section\n\nbody" + if got != want { + t.Fatalf("composeSystemPrompt() = %q, want %q", got, want) + } +} diff --git a/internal/context/source_rules_test.go b/internal/context/source_rules_test.go index b265604e..6e6e090f 100644 --- a/internal/context/source_rules_test.go +++ b/internal/context/source_rules_test.go @@ -10,6 +10,26 @@ import ( "time" ) +type fakeFileInfo struct { + name string + size int64 + modTime time.Time + dir bool +} + +func (f fakeFileInfo) Name() string { return f.name } +func (f fakeFileInfo) Size() int64 { return f.size } +func (f fakeFileInfo) ModTime() time.Time { return f.modTime } +func (f fakeFileInfo) IsDir() bool { return f.dir } +func (f fakeFileInfo) Sys() any { return nil } + +func (f fakeFileInfo) Mode() os.FileMode { + if f.dir { + return os.ModeDir | 0o755 + } + return 0o644 +} + func TestLoadProjectRulesOrdersGlobalToLocal(t *testing.T) { t.Parallel() @@ -211,3 +231,101 @@ func TestRenderProjectRulesSectionTruncatesSingleFileAndTotalBudget(t *testing.T ) } } + +func TestProjectRulesSourceReturnsCacheValidationError(t *testing.T) { + t.Parallel() + + source := &projectRulesSource{ + cache: map[string]cachedRuleDocuments{ + normalizeRuleCacheKey("/workspace"): { + snapshots: []ruleFileSnapshot{{ + Path: "/workspace/AGENTS.md", + ModTime: time.Unix(10, 0), + Size: 12, + }}, + }, + }, + statFile: func(path string) (os.FileInfo, error) { + return nil, errors.New("stat boom") + }, + loadRules: func(ctx context.Context, workdir string) ([]ruleDocument, error) { + t.Fatalf("loadRules should not run when cache validation fails") + return nil, nil + }, + } + + _, err := source.loadCachedProjectRules(context.Background(), "/workspace") + if err == nil || !strings.Contains(err.Error(), "stat boom") { + t.Fatalf("expected cache validation error, got %v", err) + } +} + +func TestProjectRulesSourceInvalidatesCacheWhenRuleFileIsMissing(t *testing.T) { + t.Parallel() + + source := &projectRulesSource{ + statFile: func(path string) (os.FileInfo, error) { + return nil, os.ErrNotExist + }, + } + + valid, err := source.isRuleCacheEntryValid(cachedRuleDocuments{ + snapshots: []ruleFileSnapshot{{ + Path: "/workspace/AGENTS.md", + ModTime: time.Unix(20, 0), + Size: 8, + }}, + }) + if err != nil { + t.Fatalf("isRuleCacheEntryValid() error = %v", err) + } + if valid { + t.Fatalf("expected missing rule file to invalidate cache") + } +} + +func TestDiscoverRuleFilesWithFinderStartsFromFileParentDirectory(t *testing.T) { + t.Parallel() + + root := t.TempDir() + child := filepath.Join(root, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("mkdir child: %v", err) + } + filePath := filepath.Join(child, "main.go") + if err := os.WriteFile(filePath, []byte("package main"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + rootRule := filepath.Join(root, projectRuleFileName) + childRule := filepath.Join(child, projectRuleFileName) + + paths, err := discoverRuleFilesWithFinder(context.Background(), filePath, func(dir string) (string, error) { + switch dir { + case child: + return childRule, nil + case root: + return rootRule, nil + default: + return "", nil + } + }) + if err != nil { + t.Fatalf("discoverRuleFilesWithFinder() error = %v", err) + } + if len(paths) != 2 || paths[0] != rootRule || paths[1] != childRule { + t.Fatalf("expected parent-to-child rule order, got %+v", paths) + } +} + +func TestFindExactRuleFileReturnsNoMatchForMissingDirectory(t *testing.T) { + t.Parallel() + + got, err := findExactRuleFile(filepath.Join(t.TempDir(), "missing")) + if err != nil { + t.Fatalf("findExactRuleFile() error = %v", err) + } + if got != "" { + t.Fatalf("expected no rule file, got %q", got) + } +} diff --git a/internal/context/source_system_test.go b/internal/context/source_system_test.go index af9ebcbd..32b1f51d 100644 --- a/internal/context/source_system_test.go +++ b/internal/context/source_system_test.go @@ -82,3 +82,77 @@ func TestCollectSystemStateReturnsContextError(t *testing.T) { t.Fatalf("expected context canceled error, got %v", err) } } + +func TestSystemStateSourceSectionsReturnsRunnerContextError(t *testing.T) { + t.Parallel() + + source := &systemStateSource{ + gitRunner: func(ctx context.Context, workdir string, args ...string) (string, error) { + return "", context.DeadlineExceeded + }, + } + + _, err := source.Sections(context.Background(), BuildInput{ + Metadata: testMetadata("/workspace"), + }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected deadline exceeded, got %v", err) + } +} + +func TestCollectSystemStateSkipsGitSummaryWhenRunnerUnavailableOrWorkdirBlank(t *testing.T) { + t.Parallel() + + state, err := collectSystemState(context.Background(), Metadata{ + Workdir: " /workspace ", + Shell: " powershell ", + Provider: " openai ", + Model: " gpt-test ", + }, nil) + if err != nil { + t.Fatalf("collectSystemState() error = %v", err) + } + if state.Git.Available { + t.Fatalf("expected git to stay unavailable without runner") + } + if state.Workdir != "/workspace" { + t.Fatalf("expected trimmed workdir, got %q", state.Workdir) + } + + state, err = collectSystemState(context.Background(), Metadata{ + Workdir: " ", + Shell: " bash ", + Provider: " local ", + Model: " mini ", + }, func(ctx context.Context, workdir string, args ...string) (string, error) { + t.Fatalf("runner should not be called for blank workdir") + return "", nil + }) + if err != nil { + t.Fatalf("collectSystemState() blank workdir error = %v", err) + } + if state.Git.Available { + t.Fatalf("expected git to stay unavailable for blank workdir") + } +} + +func TestParseGitStatusSummaryHandlesCleanDetachedAndBranchlessOutput(t *testing.T) { + t.Parallel() + + cleanState := parseGitStatusSummary("## main...origin/main\n") + if !cleanState.Available || cleanState.Branch != "main" || cleanState.Dirty { + t.Fatalf("unexpected clean state: %+v", cleanState) + } + + dirtyWithoutBranch := parseGitStatusSummary(" M internal/context/builder.go\n") + if !dirtyWithoutBranch.Available || dirtyWithoutBranch.Branch != "" || !dirtyWithoutBranch.Dirty { + t.Fatalf("unexpected dirty state without branch header: %+v", dirtyWithoutBranch) + } + + if got := parseGitBranchLine("No commits yet on feature/bootstrap"); got != "feature/bootstrap" { + t.Fatalf("expected unborn branch name, got %q", got) + } + if got := parseGitBranchLine("HEAD detached at abc123"); got != "detached" { + t.Fatalf("expected detached HEAD marker, got %q", got) + } +} diff --git a/internal/context/sources_test.go b/internal/context/sources_test.go index d78b3f1f..7337328f 100644 --- a/internal/context/sources_test.go +++ b/internal/context/sources_test.go @@ -2,6 +2,7 @@ package context import ( "context" + "errors" "os" "path/filepath" "testing" @@ -65,3 +66,15 @@ func TestProjectRulesSourceSectionsRendersRules(t *testing.T) { t.Fatalf("expected rendered project rule section") } } + +func TestCorePromptSourceSectionsHonorsCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := (corePromptSource{}).Sections(ctx, BuildInput{}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context canceled, got %v", err) + } +} From a57b86f808a30ca644d056ada7378c212fe8090a Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 5 Apr 2026 10:54:12 +0800 Subject: [PATCH 6/7] test(runtime): remove implicit gemini env dependency --- internal/runtime/runtime_test.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 0214c574..e67038d4 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -525,16 +525,10 @@ func TestServiceRunFailurePreservesExistingSessionProviderAndModel(t *testing.T) t.Parallel() manager := newRuntimeConfigManager(t) - geminiEnv := runtimeTestAPIKeyEnv(t) + "_GEMINI" - setRuntimeProviderEnv(t, geminiEnv, "gemini-key") + setRuntimeProviderEnv(t, config.GeminiDefaultAPIKeyEnv, "gemini-key") if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.SelectedProvider = config.GeminiName cfg.CurrentModel = "gemini-current-model" - for index := range cfg.Providers { - if config.NormalizeProviderName(cfg.Providers[index].Name) == config.NormalizeProviderName(config.GeminiName) { - cfg.Providers[index].APIKeyEnv = geminiEnv - } - } return nil }); err != nil { t.Fatalf("update config: %v", err) From 8c9906e168a153a700f3749bae5434dd7d78fbb3 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 5 Apr 2026 10:57:18 +0800 Subject: [PATCH 7/7] test(runtime): isolate provider envs in parallel tests --- internal/runtime/runtime_test.go | 33 ++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index e67038d4..7f3b5da5 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -524,8 +524,11 @@ func TestServiceRunPersistsSessionProviderAndModel(t *testing.T) { func TestServiceRunFailurePreservesExistingSessionProviderAndModel(t *testing.T) { t.Parallel() - manager := newRuntimeConfigManager(t) - setRuntimeProviderEnv(t, config.GeminiDefaultAPIKeyEnv, "gemini-key") + geminiEnv := runtimeTestAPIKeyEnv(t) + "_GEMINI" + manager := newRuntimeConfigManagerWithProviderEnvs(t, map[string]string{ + config.GeminiName: geminiEnv, + }) + setRuntimeProviderEnv(t, geminiEnv, "gemini-key") if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.SelectedProvider = config.GeminiName cfg.CurrentModel = "gemini-current-model" @@ -1393,8 +1396,11 @@ func TestServiceCompactManualFailureReturnsError(t *testing.T) { func TestServiceCompactUsesSessionProviderAndModelWhenPresent(t *testing.T) { t.Parallel() - manager := newRuntimeConfigManager(t) - setRuntimeProviderEnv(t, config.GeminiDefaultAPIKeyEnv, "gemini-key") + geminiEnv := runtimeTestAPIKeyEnv(t) + "_GEMINI" + manager := newRuntimeConfigManagerWithProviderEnvs(t, map[string]string{ + config.GeminiName: geminiEnv, + }) + setRuntimeProviderEnv(t, geminiEnv, "gemini-key") if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.SelectedProvider = config.GeminiName cfg.CurrentModel = "gemini-current-model" @@ -1467,8 +1473,11 @@ func TestServiceCompactUsesSessionProviderAndModelWhenPresent(t *testing.T) { func TestServiceCompactFallsBackToCurrentProviderWhenSessionMetadataMissing(t *testing.T) { t.Parallel() - manager := newRuntimeConfigManager(t) - setRuntimeProviderEnv(t, config.GeminiDefaultAPIKeyEnv, "gemini-key") + geminiEnv := runtimeTestAPIKeyEnv(t) + "_GEMINI" + manager := newRuntimeConfigManagerWithProviderEnvs(t, map[string]string{ + config.GeminiName: geminiEnv, + }) + setRuntimeProviderEnv(t, geminiEnv, "gemini-key") if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.SelectedProvider = config.GeminiName cfg.CurrentModel = "gemini-current-model" @@ -1903,6 +1912,10 @@ func TestServiceSetSessionWorkdir(t *testing.T) { } func newRuntimeConfigManager(t *testing.T) *config.Manager { + return newRuntimeConfigManagerWithProviderEnvs(t, nil) +} + +func newRuntimeConfigManagerWithProviderEnvs(t *testing.T, providerEnvs map[string]string) *config.Manager { t.Helper() apiKeyEnv := runtimeTestAPIKeyEnv(t) @@ -1919,6 +1932,14 @@ func newRuntimeConfigManager(t *testing.T) *config.Manager { break } } + for providerName, envKey := range providerEnvs { + for i := range defaults.Providers { + if config.NormalizeProviderName(defaults.Providers[i].Name) == config.NormalizeProviderName(providerName) { + defaults.Providers[i].APIKeyEnv = envKey + break + } + } + } manager := config.NewManager(config.NewLoader(t.TempDir(), defaults)) if _, err := manager.Load(context.Background()); err != nil {