From 7ed0c01af638390adfbce1151212c435ac3d1b16 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Fri, 3 Apr 2026 11:11:29 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(runtime,tui,context):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20compact=20=E5=B9=B6=E5=8F=91=E4=B8=8E=20UTF-8=20?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E5=B9=B6=E8=A1=A5=E9=BD=90=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Runtime 层新增 Run/Compact 串行互斥,避免并发写会话冲突\n- TUI 层新增 compact busy 状态,阻止 compact 期间重复触发发送/新会话\n- compact summary 截断改为按 rune 计数,避免中文多字节字符被截断\n- 清理乱码注释并为 internal/context(含 compact)补充中文 UTF-8 注释\n- 补充回归测试:UTF-8 截断、Run/Compact 串行化、TUI busy 状态 --- internal/context/builder.go | 7 +- internal/context/compact/runner.go | 542 ++++++++++++++++++++++++ internal/context/compact/runner_test.go | 506 ++++++++++++++++++++++ internal/context/metadata.go | 6 +- internal/context/prompt.go | 4 + internal/context/source_rules.go | 8 + internal/context/source_system.go | 4 + internal/context/trim.go | 2 + internal/context/types.go | 6 +- internal/runtime/runtime.go | 163 ++++++- internal/runtime/runtime_test.go | 391 ++++++++++++++++- internal/tui/commands.go | 7 +- internal/tui/commands_test.go | 16 + internal/tui/state.go | 1 + internal/tui/update.go | 118 +++++- internal/tui/update_test.go | 207 ++++++++- internal/tui/view.go | 8 +- 17 files changed, 1946 insertions(+), 50 deletions(-) create mode 100644 internal/context/compact/runner.go create mode 100644 internal/context/compact/runner_test.go diff --git a/internal/context/builder.go b/internal/context/builder.go index abd9523a..eac19fa1 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -2,19 +2,20 @@ package context import "context" -// DefaultBuilder preserves the current runtime context-building behavior. +// DefaultBuilder 负责将运行时状态组装为模型可消费的上下文。 type DefaultBuilder struct { gitRunner gitCommandRunner } -// NewBuilder returns the default context builder implementation. +// NewBuilder 返回默认上下文构建器实现。 func NewBuilder() Builder { return &DefaultBuilder{ gitRunner: runGitCommand, } } -// Build assembles the provider-facing context for the current round. +// Build 构建单轮请求所需的 system prompt 与消息窗口。 +// 失败时直接返回错误,不吞掉规则加载或系统状态采集失败。 func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResult, error) { if err := ctx.Err(); err != nil { return BuildResult{}, err diff --git a/internal/context/compact/runner.go b/internal/context/compact/runner.go new file mode 100644 index 00000000..34050fbd --- /dev/null +++ b/internal/context/compact/runner.go @@ -0,0 +1,542 @@ +package compact + +import ( + "context" + cryptorand "crypto/rand" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + goruntime "runtime" + "strings" + "time" + + "neo-code/internal/config" + "neo-code/internal/provider" +) + +// Mode 表示 compact 执行模式。 +type Mode string + +const ( + // ModeMicro 只替换较早且较长的 tool 结果,保留最近窗口的细节上下文。 + ModeMicro Mode = "micro" + // ModeManual 显式触发人工压缩,按策略写入 summary 并重排上下文。 + ModeManual Mode = "manual" +) + +// ErrorMode 表示 compact 结果中的错误分类。 +type ErrorMode string + +const ( + ErrorModeNone ErrorMode = "none" +) + +// Input 是一次 compact 执行的输入。 +type Input struct { + Mode Mode + SessionID string + Workdir string + Messages []provider.Message + Config config.CompactConfig +} + +// Metrics 是 compact 过程的统计信息。 +type Metrics struct { + BeforeChars int `json:"before_chars"` + AfterChars int `json:"after_chars"` + SavedRatio float64 `json:"saved_ratio"` + TriggerMode string `json:"trigger_mode"` +} + +// Result 是 compact 执行结果。 +type Result struct { + Messages []provider.Message `json:"messages"` + Metrics Metrics `json:"metrics"` + TranscriptID string `json:"transcript_id"` + TranscriptPath string `json:"transcript_path"` + Applied bool `json:"applied"` + ErrorMode ErrorMode `json:"error_mode"` +} + +// Runner 定义 compact 执行契约。 +type Runner interface { + Run(ctx context.Context, input Input) (Result, error) +} + +// Service 是 compact 模块的默认实现。 +type Service 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 +} + +// NewRunner 返回默认 compact 执行器。 +func NewRunner() *Service { + return &Service{ + now: time.Now, + randomToken: randomTranscriptToken, + userHomeDir: os.UserHomeDir, + mkdirAll: os.MkdirAll, + writeFile: os.WriteFile, + rename: os.Rename, + remove: os.Remove, + } +} + +// Run 执行 compact 主流程: +// 1. 归一化配置并生成基础结果。 +// 2. 校验是否满足模式触发条件。 +// 3. 落盘当前会话 transcript,便于追溯。 +// 4. 按模式执行压缩并计算统计信息。 +func (s *Service) Run(ctx context.Context, input Input) (Result, error) { + if err := ctx.Err(); err != nil { + return Result{}, err + } + + cfg := normalizeCompactConfig(input.Config) + messages := cloneMessages(input.Messages) + + beforeChars := countMessageChars(messages) + base := Result{ + Messages: messages, + Applied: false, + ErrorMode: ErrorModeNone, + Metrics: Metrics{ + BeforeChars: beforeChars, + AfterChars: beforeChars, + SavedRatio: 0, + TriggerMode: string(input.Mode), + }, + } + + // micro 模式只在开启配置且存在可压缩候选时执行,避免无意义改写。 + switch input.Mode { + case ModeMicro: + if !cfg.MicroEnabled || !hasMicroCandidate(messages, cfg) { + return base, nil + } + case ModeManual: + // manual 模式由用户显式触发,总是进入评估。 + default: + return Result{}, fmt.Errorf("compact: unsupported mode %q", input.Mode) + } + + // 先保存原始 transcript,再做内容替换,确保失败时仍可回溯完整上下文。 + transcriptID, transcriptPath, err := s.saveTranscript(messages, strings.TrimSpace(input.SessionID), strings.TrimSpace(input.Workdir)) + if err != nil { + return Result{}, err + } + base.TranscriptID = transcriptID + base.TranscriptPath = transcriptPath + + var ( + next []provider.Message + applied bool + ) + + switch input.Mode { + case ModeMicro: + next, applied = microCompact(messages, cfg) + case ModeManual: + next, applied, err = manualCompact(messages, cfg) + if err != nil { + return Result{}, err + } + } + + afterChars := countMessageChars(next) + result := base + result.Messages = next + result.Applied = applied + result.Metrics.AfterChars = afterChars + if beforeChars > 0 { + result.Metrics.SavedRatio = float64(beforeChars-afterChars) / float64(beforeChars) + } + return result, nil +} + +// hasMicroCandidate 判断是否存在可被 micro 模式替换的旧 tool 输出。 +// 规则:仅检查“非最近 N 条 tool 结果”且正文长度达到阈值的候选。 +func hasMicroCandidate(messages []provider.Message, cfg config.CompactConfig) bool { + toolIndices := make([]int, 0, len(messages)) + for i, message := range messages { + if message.Role == provider.RoleTool { + toolIndices = append(toolIndices, i) + } + } + if len(toolIndices) <= cfg.ToolResultKeepRecent { + return false + } + candidateCount := len(toolIndices) - cfg.ToolResultKeepRecent + for i := 0; i < candidateCount; i++ { + if len(messages[toolIndices[i]].Content) >= cfg.ToolResultPlaceholderMinChars { + return true + } + } + return false +} + +// microCompact 仅对历史较早的长 tool 输出替换为占位摘要,保留最近输出细节。 +func microCompact(messages []provider.Message, cfg config.CompactConfig) ([]provider.Message, bool) { + next := cloneMessages(messages) + + toolIndices := make([]int, 0, len(next)) + for i, message := range next { + if message.Role == provider.RoleTool { + toolIndices = append(toolIndices, i) + } + } + + if len(toolIndices) <= cfg.ToolResultKeepRecent { + return next, false + } + + toolNameByCallID := buildToolNameIndex(next) + candidateCount := len(toolIndices) - cfg.ToolResultKeepRecent + applied := false + for i := 0; i < candidateCount; i++ { + idx := toolIndices[i] + message := next[idx] + if len(message.Content) < cfg.ToolResultPlaceholderMinChars { + continue + } + + toolName := strings.TrimSpace(toolNameByCallID[message.ToolCallID]) + if toolName == "" { + toolName = "unknown_tool" + } + placeholder := fmt.Sprintf("[Previous tool used: %s]", toolName) + if message.Content == placeholder { + continue + } + message.Content = placeholder + next[idx] = message + applied = true + } + + return next, applied +} + +// buildToolNameIndex 从 assistant 的 tool_calls 中提取 call_id -> tool_name 索引。 +func buildToolNameIndex(messages []provider.Message) map[string]string { + index := make(map[string]string) + for _, message := range messages { + if message.Role != provider.RoleAssistant || len(message.ToolCalls) == 0 { + continue + } + for _, call := range message.ToolCalls { + id := strings.TrimSpace(call.ID) + name := strings.TrimSpace(call.Name) + if id == "" || name == "" { + continue + } + index[id] = name + } + } + return index +} + +type span struct { + start int + end int +} + +// collectSpans 将消息序列切分为“对话跨度”。 +// assistant(tool_calls)+连续 tool_results 会被视为单个跨度,避免打断调用链。 +func collectSpans(messages []provider.Message) []span { + 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}) + } + return spans +} + +// manualCompact 按配置策略执行手动压缩。 +func manualCompact(messages []provider.Message, cfg config.CompactConfig) ([]provider.Message, bool, error) { + strategy := strings.ToLower(strings.TrimSpace(cfg.ManualStrategy)) + switch strategy { + case config.CompactManualStrategyKeepRecent: + return manualCompactKeepRecent(messages, cfg) + case config.CompactManualStrategyFullReplace: + return manualCompactFullReplace(messages, cfg) + default: + return nil, false, fmt.Errorf("compact: manual strategy %q is not supported", cfg.ManualStrategy) + } +} + +// manualCompactKeepRecent 保留最近 N 个跨度,将更早历史折叠为一条 summary。 +func manualCompactKeepRecent(messages []provider.Message, cfg config.CompactConfig) ([]provider.Message, bool, error) { + spans := collectSpans(messages) + if len(spans) <= cfg.ManualKeepRecentSpans { + return cloneMessages(messages), false, nil + } + + keepStart := spans[len(spans)-cfg.ManualKeepRecentSpans].start + removed := cloneMessages(messages[:keepStart]) + kept := cloneMessages(messages[keepStart:]) + + summary, err := buildSummary(removed, len(spans)-cfg.ManualKeepRecentSpans, 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 +} + +// manualCompactFullReplace 将整段历史替换为单条 summary。 +func manualCompactFullReplace(messages []provider.Message, cfg config.CompactConfig) ([]provider.Message, bool, error) { + if len(messages) == 0 { + return nil, false, nil + } + spans := collectSpans(messages) + summary, err := buildSummary(cloneMessages(messages), len(spans), cfg) + if err != nil { + return nil, false, err + } + + return []provider.Message{{Role: provider.RoleAssistant, Content: summary}}, true, nil +} + +// buildSummary 生成手动压缩 summary 文本,并保证 done/in_progress 结构有效。 +func buildSummary(removed []provider.Message, removedSpans int, cfg config.CompactConfig) (string, error) { + toolNames := make([]string, 0, 8) + seenTools := map[string]struct{}{} + for _, message := range removed { + for _, call := range message.ToolCalls { + name := strings.TrimSpace(call.Name) + if name == "" { + continue + } + if _, exists := seenTools[name]; exists { + continue + } + seenTools[name] = struct{}{} + toolNames = append(toolNames, name) + } + } + toolSummary := "none" + if len(toolNames) > 0 { + toolSummary = strings.Join(toolNames, ", ") + } + + summary := fmt.Sprintf(strings.Join([]string{ + "[compact_summary]", + "done:", + "- Archived %d historical spans (%d messages).", + "", + "in_progress:", + "- Continue from the retained recent context window.", + "", + "decisions:", + "- manual_strategy=%s", + "- manual_keep_recent_spans=%d", + "", + "code_changes:", + "- Older context outside the recent window was replaced by this summary.", + "- Historical tool calls in archived spans: %s", + "", + "constraints:", + "- Assistant tool_calls and tool_result pairs remain intact in retained spans.", + }, "\n"), removedSpans, len(removed), cfg.ManualStrategy, cfg.ManualKeepRecentSpans, toolSummary) + + return validateSummary(summary, cfg.MaxSummaryChars) +} + +// validateSummary 校验 summary 的最小结构,并在需要时按字符数裁剪。 +// maxChars 的语义是“字符数(rune)”,不是字节数。 +func validateSummary(summary string, maxChars int) (string, error) { + summary = strings.TrimSpace(summary) + if maxChars > 0 { + runes := []rune(summary) + if len(runes) > maxChars { + summary = strings.TrimSpace(string(runes[:maxChars])) + } + } + + hasDone := sectionHasContent(summary, "done") + hasInProgress := sectionHasContent(summary, "in_progress") + if !hasDone && !hasInProgress { + return "", errors.New("compact: summary requires done or in_progress content") + } + return summary, nil +} + +// sectionHasContent 判断目标 section 是否至少包含一条列表项内容。 +func sectionHasContent(summary string, section string) bool { + pattern := fmt.Sprintf(`(?ms)^%s:\s*\n\s*-\s+.+?(\n\w+?:|\z)`, regexp.QuoteMeta(section)) + re, err := regexp.Compile(pattern) + if err != nil { + return false + } + return re.MatchString(summary) +} + +// transcriptLine 是 transcript JSONL 的单行结构。 +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"` +} + +// saveTranscript 将 compact 前消息落盘到项目目录,使用 tmp+rename 保证原子提交。 +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 := filepath.Join(home, ".neocode", "projects", projectHash, ".transcripts") + if err := s.mkdirAll(dir, 0o755); err != nil { + return "", "", fmt.Errorf("compact: create transcript dir: %w", err) + } + + sessionID = sanitizeID(sessionID) + if sessionID == "" { + sessionID = "draft" + } + 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+".jsonl") + tmpPath := transcriptPath + ".tmp" + + 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 在非 Windows 平台使用 0600,避免 transcript 对其他用户可读。 +func transcriptFileMode() os.FileMode { + if goruntime.GOOS == "windows" { + return 0o644 + } + return 0o600 +} + +// randomTranscriptToken 生成短随机后缀,降低同时间戳命名冲突概率。 +func randomTranscriptToken() (string, error) { + entropy := make([]byte, 4) + if _, err := cryptorand.Read(entropy); err != nil { + return "", err + } + return hex.EncodeToString(entropy), nil +} + +// hashProject 将 workdir 映射到固定哈希目录名,避免路径泄漏和非法字符问题。 +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, "_") +} + +// 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 统计消息字符预算(当前按字符串字节长度计算)。 +func countMessageChars(messages []provider.Message) int { + total := 0 + for _, message := range messages { + total += len(message.Role) + total += len(message.Content) + total += len(message.ToolCallID) + for _, call := range message.ToolCalls { + total += len(call.ID) + total += len(call.Name) + total += len(call.Arguments) + } + } + return total +} + +// normalizeCompactConfig 填补 compact 配置默认值并兜底策略字段。 +func normalizeCompactConfig(cfg config.CompactConfig) config.CompactConfig { + defaults := config.Default().Context.Compact + cfg.ApplyDefaults(defaults) + if strings.TrimSpace(cfg.ManualStrategy) == "" { + cfg.ManualStrategy = config.CompactManualStrategyKeepRecent + } + return cfg +} diff --git a/internal/context/compact/runner_test.go b/internal/context/compact/runner_test.go new file mode 100644 index 00000000..966612fc --- /dev/null +++ b/internal/context/compact/runner_test.go @@ -0,0 +1,506 @@ +package compact + +import ( + "context" + "errors" + "os" + "path/filepath" + goruntime "runtime" + "strings" + "testing" + "time" + "unicode/utf8" + + "neo-code/internal/config" + "neo-code/internal/provider" +) + +func TestMicroCompactReplacesOnlyOldLongToolResults(t *testing.T) { + t.Parallel() + + runner := NewRunner() + home := t.TempDir() + runner.userHomeDir = func() (string, error) { return home, nil } + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "start"}, + {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}}}, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: strings.Repeat("A", 40)}, + {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-2", Name: "filesystem_grep", Arguments: "{}"}}}, + {Role: provider.RoleTool, ToolCallID: "call-2", Content: "short"}, + {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-3", Name: "bash", Arguments: "{}"}}}, + {Role: provider.RoleTool, ToolCallID: "call-3", Content: strings.Repeat("B", 50)}, + } + + result, err := runner.Run(context.Background(), Input{ + Mode: ModeMicro, + SessionID: "session-a", + Workdir: t.TempDir(), + Messages: messages, + Config: config.CompactConfig{ + MicroEnabled: true, + ToolResultKeepRecent: 1, + ToolResultPlaceholderMinChars: 10, + ManualStrategy: config.CompactManualStrategyKeepRecent, + ManualKeepRecentSpans: 6, + MaxSummaryChars: 1200, + }, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if !result.Applied { + t.Fatalf("expected micro compact applied") + } + if !strings.Contains(result.Messages[2].Content, "filesystem_read_file") { + t.Fatalf("expected first old tool result to be replaced, got %q", result.Messages[2].Content) + } + if result.Messages[4].Content != "short" { + t.Fatalf("expected short old tool result unchanged, got %q", result.Messages[4].Content) + } + if result.Messages[6].Content == "[Previous tool used: bash]" { + t.Fatalf("expected recent tool result to be retained") + } + if result.TranscriptID == "" || result.TranscriptPath == "" { + t.Fatalf("expected transcript metadata, got %+v", result) + } + if _, err := os.Stat(result.TranscriptPath); err != nil { + t.Fatalf("expected transcript file: %v", err) + } +} + +func TestMicroCompactFallsBackToUnknownTool(t *testing.T) { + t.Parallel() + + runner := NewRunner() + home := t.TempDir() + runner.userHomeDir = func() (string, error) { return home, nil } + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "start"}, + {Role: provider.RoleTool, ToolCallID: "missing-call", Content: strings.Repeat("X", 24)}, + {Role: provider.RoleTool, ToolCallID: "recent-call", Content: strings.Repeat("Y", 24)}, + } + + result, err := runner.Run(context.Background(), Input{ + Mode: ModeMicro, + SessionID: "session-b", + Workdir: t.TempDir(), + Messages: messages, + Config: config.CompactConfig{ + MicroEnabled: true, + ToolResultKeepRecent: 1, + ToolResultPlaceholderMinChars: 10, + ManualStrategy: config.CompactManualStrategyKeepRecent, + ManualKeepRecentSpans: 6, + MaxSummaryChars: 1200, + }, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if got := result.Messages[1].Content; got != "[Previous tool used: unknown_tool]" { + t.Fatalf("expected unknown tool placeholder, got %q", got) + } +} + +func TestManualCompactAddsSummaryAndKeepsRecentSpans(t *testing.T) { + t.Parallel() + + runner := NewRunner() + home := t.TempDir() + runner.userHomeDir = func() (string, error) { return home, nil } + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "old requirement"}, + {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-old", Name: "filesystem_grep", Arguments: "{}"}}}, + {Role: provider.RoleTool, ToolCallID: "call-old", Content: "old result"}, + {Role: provider.RoleAssistant, Content: "latest answer"}, + } + + result, err := runner.Run(context.Background(), Input{ + Mode: ModeManual, + SessionID: "session-c", + Workdir: t.TempDir(), + Messages: messages, + Config: config.CompactConfig{ + MicroEnabled: false, + ToolResultKeepRecent: 3, + ToolResultPlaceholderMinChars: 100, + ManualStrategy: config.CompactManualStrategyKeepRecent, + ManualKeepRecentSpans: 1, + MaxSummaryChars: 1200, + }, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if !result.Applied { + t.Fatalf("expected manual compact applied") + } + if len(result.Messages) != 2 { + t.Fatalf("expected summary + 1 kept span, got %d", len(result.Messages)) + } + summary := result.Messages[0] + if summary.Role != provider.RoleAssistant { + t.Fatalf("expected summary role assistant, got %q", summary.Role) + } + for _, section := range []string{"done:", "in_progress:", "decisions:", "code_changes:", "constraints:"} { + if !strings.Contains(summary.Content, section) { + t.Fatalf("expected summary to include section %q, got %q", section, summary.Content) + } + } + if result.Messages[1].Content != "latest answer" { + t.Fatalf("expected newest span kept, got %+v", result.Messages[1]) + } +} + +func TestManualCompactWritesTranscriptJSONL(t *testing.T) { + t.Parallel() + + runner := NewRunner() + home := t.TempDir() + runner.userHomeDir = func() (string, error) { return home, nil } + + result, err := runner.Run(context.Background(), Input{ + Mode: ModeManual, + SessionID: "session-jsonl", + Workdir: filepath.Join(home, "workspace"), + Messages: []provider.Message{ + {Role: provider.RoleUser, Content: "hello"}, + }, + Config: config.CompactConfig{ + ManualStrategy: config.CompactManualStrategyKeepRecent, + ManualKeepRecentSpans: 6, + ToolResultKeepRecent: 3, + ToolResultPlaceholderMinChars: 100, + MaxSummaryChars: 1200, + }, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + data, err := os.ReadFile(result.TranscriptPath) + if err != nil { + t.Fatalf("read transcript: %v", err) + } + if !strings.Contains(string(data), `"role":"user"`) { + t.Fatalf("expected jsonl content, got %q", string(data)) + } + if !strings.Contains(filepath.ToSlash(result.TranscriptPath), "/.neocode/projects/") { + t.Fatalf("expected transcript path under .neocode/projects, got %q", result.TranscriptPath) + } + if !strings.HasPrefix(result.TranscriptID, "transcript_") { + t.Fatalf("unexpected transcript id: %q", result.TranscriptID) + } + if goruntime.GOOS != "windows" { + info, err := os.Stat(result.TranscriptPath) + if err != nil { + t.Fatalf("stat transcript: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("expected transcript mode 0600, got %04o", got) + } + } +} + +func TestManualCompactFailsWhenTranscriptWriteFails(t *testing.T) { + t.Parallel() + + runner := NewRunner() + runner.userHomeDir = func() (string, error) { return t.TempDir(), nil } + runner.mkdirAll = func(path string, perm os.FileMode) error { + return errors.New("disk full") + } + + _, err := runner.Run(context.Background(), Input{ + Mode: ModeManual, + SessionID: "session-fail", + Workdir: t.TempDir(), + Messages: []provider.Message{ + {Role: provider.RoleUser, Content: "hello"}, + }, + Config: config.CompactConfig{ + ManualStrategy: config.CompactManualStrategyKeepRecent, + ManualKeepRecentSpans: 6, + ToolResultKeepRecent: 3, + ToolResultPlaceholderMinChars: 100, + MaxSummaryChars: 1200, + }, + }) + if err == nil || !strings.Contains(err.Error(), "disk full") { + t.Fatalf("expected transcript write failure, got %v", err) + } +} + +func TestManualCompactFullReplaceRewritesAllMessages(t *testing.T) { + t.Parallel() + + runner := NewRunner() + home := t.TempDir() + runner.userHomeDir = func() (string, error) { return home, nil } + + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "old requirement"}, + {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-old", Name: "filesystem_grep", Arguments: "{}"}}}, + {Role: provider.RoleTool, ToolCallID: "call-old", Content: "old result"}, + {Role: provider.RoleAssistant, Content: "latest answer"}, + } + + result, err := runner.Run(context.Background(), Input{ + Mode: ModeManual, + SessionID: "session-full-replace", + Workdir: t.TempDir(), + Messages: messages, + Config: config.CompactConfig{ + MicroEnabled: true, + ToolResultKeepRecent: 3, + ToolResultPlaceholderMinChars: 100, + ManualStrategy: config.CompactManualStrategyFullReplace, + ManualKeepRecentSpans: 6, + MaxSummaryChars: 1200, + }, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if !result.Applied { + t.Fatalf("expected full_replace compact applied") + } + if len(result.Messages) != 1 { + t.Fatalf("expected single summary message, got %d", len(result.Messages)) + } + if result.Messages[0].Role != provider.RoleAssistant { + t.Fatalf("expected summary role assistant, got %q", result.Messages[0].Role) + } + for _, section := range []string{"done:", "in_progress:", "decisions:", "code_changes:", "constraints:"} { + if !strings.Contains(result.Messages[0].Content, section) { + t.Fatalf("expected summary section %q, got %q", section, result.Messages[0].Content) + } + } +} + +func TestSaveTranscriptUsesUniqueIDWithinSameTimestamp(t *testing.T) { + t.Parallel() + + runner := NewRunner() + home := t.TempDir() + runner.userHomeDir = func() (string, error) { return home, nil } + fixedNow := time.Unix(1712052000, 123456789) + runner.now = func() time.Time { return fixedNow } + tokenSeq := []string{"a1b2c3d4", "b2c3d4e5"} + runner.randomToken = func() (string, error) { + if len(tokenSeq) == 0 { + return "", errors.New("empty token sequence") + } + next := tokenSeq[0] + tokenSeq = tokenSeq[1:] + return next, nil + } + + input := Input{ + Mode: ModeManual, + SessionID: "session-dup-safe", + Workdir: t.TempDir(), + Messages: []provider.Message{ + {Role: provider.RoleUser, Content: "hello"}, + {Role: provider.RoleAssistant, Content: "world"}, + }, + Config: config.CompactConfig{ + ManualStrategy: config.CompactManualStrategyFullReplace, + ManualKeepRecentSpans: 6, + ToolResultKeepRecent: 3, + ToolResultPlaceholderMinChars: 100, + MaxSummaryChars: 1200, + }, + } + + first, err := runner.Run(context.Background(), input) + if err != nil { + t.Fatalf("first Run() error = %v", err) + } + second, err := runner.Run(context.Background(), input) + if err != nil { + t.Fatalf("second Run() error = %v", err) + } + if first.TranscriptID == second.TranscriptID { + t.Fatalf("expected distinct transcript ids, got %q", first.TranscriptID) + } + if first.TranscriptPath == second.TranscriptPath { + t.Fatalf("expected distinct transcript paths, got %q", first.TranscriptPath) + } + if _, err := os.Stat(first.TranscriptPath); err != nil { + t.Fatalf("first transcript file missing: %v", err) + } + if _, err := os.Stat(second.TranscriptPath); err != nil { + t.Fatalf("second transcript file missing: %v", err) + } +} + +func TestRunRejectsUnsupportedMode(t *testing.T) { + t.Parallel() + + runner := NewRunner() + _, err := runner.Run(context.Background(), Input{ + Mode: Mode("invalid"), + SessionID: "session-invalid-mode", + Workdir: t.TempDir(), + Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}, + Config: config.CompactConfig{ + ManualStrategy: config.CompactManualStrategyKeepRecent, + ManualKeepRecentSpans: 6, + ToolResultKeepRecent: 3, + ToolResultPlaceholderMinChars: 100, + MaxSummaryChars: 1200, + }, + }) + if err == nil || !strings.Contains(err.Error(), "unsupported mode") { + t.Fatalf("expected unsupported mode error, got %v", err) + } +} + +func TestRunManualRejectsUnsupportedStrategy(t *testing.T) { + t.Parallel() + + runner := NewRunner() + home := t.TempDir() + runner.userHomeDir = func() (string, error) { return home, nil } + runner.randomToken = func() (string, error) { return "token0001", nil } + + _, err := runner.Run(context.Background(), Input{ + Mode: ModeManual, + SessionID: "session-invalid-strategy", + Workdir: t.TempDir(), + Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}, + Config: config.CompactConfig{ + ManualStrategy: "unknown_strategy", + ManualKeepRecentSpans: 6, + ToolResultKeepRecent: 3, + ToolResultPlaceholderMinChars: 100, + MaxSummaryChars: 1200, + }, + }) + if err == nil || !strings.Contains(err.Error(), "not supported") { + t.Fatalf("expected unsupported strategy error, got %v", err) + } +} + +func TestRunManualFullReplaceNoMessagesIsNoop(t *testing.T) { + t.Parallel() + + runner := NewRunner() + home := t.TempDir() + runner.userHomeDir = func() (string, error) { return home, nil } + runner.randomToken = func() (string, error) { return "token0002", nil } + + result, err := runner.Run(context.Background(), Input{ + Mode: ModeManual, + SessionID: "session-empty-full-replace", + Workdir: t.TempDir(), + Messages: nil, + Config: config.CompactConfig{ + ManualStrategy: config.CompactManualStrategyFullReplace, + ManualKeepRecentSpans: 6, + ToolResultKeepRecent: 3, + ToolResultPlaceholderMinChars: 100, + MaxSummaryChars: 1200, + }, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if result.Applied { + t.Fatalf("expected no-op when full_replace runs on empty messages") + } + if len(result.Messages) != 0 { + t.Fatalf("expected no messages, got %+v", result.Messages) + } +} + +func TestSaveTranscriptHandlesHomeDirAndRenameFailures(t *testing.T) { + t.Parallel() + + t.Run("user home lookup failure", func(t *testing.T) { + t.Parallel() + + runner := NewRunner() + runner.userHomeDir = func() (string, error) { return "", errors.New("no home") } + _, _, err := runner.saveTranscript([]provider.Message{{Role: provider.RoleUser, Content: "hello"}}, "s1", t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "resolve user home") { + t.Fatalf("expected home dir failure, got %v", err) + } + }) + + t.Run("rename failure removes temp file", func(t *testing.T) { + t.Parallel() + + runner := NewRunner() + home := t.TempDir() + runner.userHomeDir = func() (string, error) { return home, nil } + runner.randomToken = func() (string, error) { return "token0003", nil } + + removedPath := "" + runner.rename = func(oldPath, newPath string) error { + return errors.New("rename failed") + } + runner.remove = func(path string) error { + removedPath = path + return nil + } + + _, _, err := runner.saveTranscript( + []provider.Message{{Role: provider.RoleUser, Content: "hello"}}, + "s2", + filepath.Join(home, "workspace"), + ) + if err == nil || !strings.Contains(err.Error(), "commit transcript") { + t.Fatalf("expected rename commit failure, got %v", err) + } + if strings.TrimSpace(removedPath) == "" || !strings.HasSuffix(removedPath, ".tmp") { + t.Fatalf("expected temp file cleanup, got %q", removedPath) + } + }) +} + +func TestValidateSummaryRequiresDoneOrInProgress(t *testing.T) { + t.Parallel() + + _, err := validateSummary(strings.Join([]string{ + "[compact_summary]", + "decisions:", + "- none", + "constraints:", + "- keep safety checks", + }, "\n"), 0) + if err == nil || !strings.Contains(err.Error(), "requires done or in_progress content") { + t.Fatalf("expected summary section validation failure, got %v", err) + } +} + +func TestValidateSummaryTruncatesByRune(t *testing.T) { + t.Parallel() + + summary := strings.Join([]string{ + "[compact_summary]", + "done:", + "- 已完成:修复 compact 中文截断问题并补齐注释。", + "", + "in_progress:", + "- 继续执行回归测试。", + }, "\n") + maxChars := 40 + + got, err := validateSummary(summary, maxChars) + if err != nil { + t.Fatalf("validateSummary() error = %v", err) + } + if !utf8.ValidString(got) { + t.Fatalf("expected valid UTF-8 summary, got %q", got) + } + if utf8.RuneCountInString(got) > maxChars { + t.Fatalf("expected rune count <= %d, got %d", maxChars, utf8.RuneCountInString(got)) + } + if utf8.RuneCountInString(got) >= utf8.RuneCountInString(summary) { + t.Fatalf("expected summary to be truncated, got %q", got) + } +} diff --git a/internal/context/metadata.go b/internal/context/metadata.go index bec1f559..86b0eae3 100644 --- a/internal/context/metadata.go +++ b/internal/context/metadata.go @@ -1,6 +1,6 @@ package context -// Metadata contains the non-message runtime state needed by context sources. +// Metadata 描述构建上下文时需要注入的非消息元信息。 type Metadata struct { Workdir string Shell string @@ -8,14 +8,14 @@ type Metadata struct { Model string } -// GitState is the summarized git metadata exposed to the prompt builder. +// GitState 是注入到 prompt 的 Git 摘要信息。 type GitState struct { Available bool Branch string Dirty bool } -// SystemState is the summarized runtime metadata exposed to the prompt builder. +// SystemState 是注入到 prompt 的运行时环境摘要。 type SystemState struct { Workdir string Shell string diff --git a/internal/context/prompt.go b/internal/context/prompt.go index 22cc66b7..c7eb795a 100644 --- a/internal/context/prompt.go +++ b/internal/context/prompt.go @@ -61,6 +61,10 @@ func composeSystemPrompt(sections ...promptSection) string { return strings.Join(rendered, "\n\n") } +// renderPromptSection 将一个 section 渲染为 Markdown 块: +// ## Title +// +// Content func renderPromptSection(section promptSection) string { title := strings.TrimSpace(section.title) content := strings.TrimSpace(section.content) diff --git a/internal/context/source_rules.go b/internal/context/source_rules.go index 31b89ad1..8dfc8d02 100644 --- a/internal/context/source_rules.go +++ b/internal/context/source_rules.go @@ -23,6 +23,7 @@ type ruleDocument struct { type ruleFileFinder func(string) (string, error) +// loadProjectRules 按目录层级加载当前工作区可见的 AGENTS.md 规则文档。 func loadProjectRules(ctx context.Context, workdir string) ([]ruleDocument, error) { paths, err := discoverRuleFiles(ctx, workdir) if err != nil { @@ -32,6 +33,7 @@ func loadProjectRules(ctx context.Context, workdir string) ([]ruleDocument, erro return loadRuleDocuments(ctx, paths, os.ReadFile) } +// loadRuleDocuments 读取并裁剪单文件规则内容,避免超长规则直接挤爆 prompt。 func loadRuleDocuments(ctx context.Context, paths []string, readFile func(string) ([]byte, error)) ([]ruleDocument, error) { documents := make([]ruleDocument, 0, len(paths)) for _, path := range paths { @@ -59,6 +61,8 @@ func discoverRuleFiles(ctx context.Context, workdir string) ([]string, error) { return discoverRuleFilesWithFinder(ctx, workdir, findExactRuleFile) } +// discoverRuleFilesWithFinder 从 workdir 逐级向上查找 AGENTS.md,并按“根到叶”顺序返回。 +// 这样更外层规则先出现,靠近项目目录的规则后出现,便于后续覆盖解释。 func discoverRuleFilesWithFinder(ctx context.Context, workdir string, finder ruleFileFinder) ([]string, error) { workdir = strings.TrimSpace(workdir) if workdir == "" { @@ -98,6 +102,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 { @@ -119,6 +124,7 @@ func findExactRuleFile(dir string) (string, error) { return "", nil } +// renderProjectRulesSection 将多份规则拼接到同一 prompt section,并受总字符预算约束。 func renderProjectRulesSection(documents []ruleDocument) promptSection { if len(documents) == 0 { return promptSection{} @@ -184,6 +190,7 @@ func renderRuleDocumentChunk(document ruleDocument) string { return builder.String() } +// renderRuleDocumentChunkWithinBudget 在预算内渲染单个规则块,优先保留标题与尽可能多的正文。 func renderRuleDocumentChunkWithinBudget(document ruleDocument, budget int) string { if budget <= 0 { return "" @@ -224,6 +231,7 @@ func renderRuleDocumentChunkWithinBudget(document ruleDocument, budget int) stri return header + body.String() } +// truncateRunes 按字符数裁剪字符串,避免中文等多字节字符被切坏。 func truncateRunes(input string, max int) (string, bool) { if max <= 0 { return "", input != "" diff --git a/internal/context/source_system.go b/internal/context/source_system.go index 6ee12e0d..25dcbc00 100644 --- a/internal/context/source_system.go +++ b/internal/context/source_system.go @@ -10,6 +10,8 @@ import ( type gitCommandRunner func(ctx context.Context, workdir string, args ...string) (string, error) +// collectSystemState 收集当前运行环境快照。 +// Git 不可用时降级为仅返回基础信息;context 取消/超时则直接返回错误。 func collectSystemState(ctx context.Context, metadata Metadata, runner gitCommandRunner) (SystemState, error) { state := SystemState{ Workdir: strings.TrimSpace(metadata.Workdir), @@ -48,6 +50,7 @@ func collectSystemState(ctx context.Context, metadata Metadata, runner gitComman return state, nil } +// renderSystemStateSection 将运行环境快照转换为 prompt section。 func renderSystemStateSection(state SystemState) promptSection { lines := []string{ fmt.Sprintf("- workdir: `%s`", promptValue(state.Workdir)), @@ -89,6 +92,7 @@ func promptValue(value string) string { return value } +// isContextError 用于区分“调用被取消”与“命令本身失败”。 func isContextError(err error) bool { return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) } diff --git a/internal/context/trim.go b/internal/context/trim.go index 8947e7f3..af76f316 100644 --- a/internal/context/trim.go +++ b/internal/context/trim.go @@ -4,6 +4,8 @@ import "neo-code/internal/provider" const maxContextTurns = 10 +// trimMessages 将消息窗口裁剪为最近 N 个“对话跨度”。 +// 当 assistant 含 tool_calls 时,会把后续连续 tool 结果视为同一跨度,避免打断调用链。 func trimMessages(messages []provider.Message) []provider.Message { if len(messages) <= maxContextTurns { return append([]provider.Message(nil), messages...) diff --git a/internal/context/types.go b/internal/context/types.go index 6eb516dd..5be2e766 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -6,18 +6,18 @@ import ( "neo-code/internal/provider" ) -// Builder builds the provider-facing context for a single model round. +// Builder 定义上下文构建契约:将运行时状态转换为模型请求上下文。 type Builder interface { Build(ctx context.Context, input BuildInput) (BuildResult, error) } -// BuildInput contains the runtime state needed to assemble model context. +// BuildInput 是构建上下文所需输入。 type BuildInput struct { Messages []provider.Message Metadata Metadata } -// BuildResult is the provider-facing context produced for a single round. +// BuildResult 是传给 provider 的上下文结果。 type BuildResult struct { SystemPrompt string Messages []provider.Message diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index a9ea5dac..9d2f3c17 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -12,6 +12,7 @@ import ( "neo-code/internal/config" agentcontext "neo-code/internal/context" + contextcompact "neo-code/internal/context/compact" "neo-code/internal/provider" "neo-code/internal/tools" ) @@ -30,6 +31,7 @@ const ( type Runtime interface { Run(ctx context.Context, input UserInput) error + Compact(ctx context.Context, input CompactInput) (CompactResult, error) CancelActiveRun() bool Events() <-chan RuntimeEvent ListSessions(ctx context.Context) ([]SessionSummary, error) @@ -42,6 +44,36 @@ type UserInput struct { Content string } +type CompactInput struct { + SessionID string + RunID string +} + +type CompactResult struct { + Applied bool + BeforeChars int + AfterChars int + SavedRatio float64 + TriggerMode string + TranscriptID string + TranscriptPath string +} + +type CompactDonePayload struct { + Applied bool `json:"applied"` + BeforeChars int `json:"before_chars"` + AfterChars int `json:"after_chars"` + SavedRatio float64 `json:"saved_ratio"` + TriggerMode string `json:"trigger_mode"` + TranscriptID string `json:"transcript_id"` + TranscriptPath string `json:"transcript_path"` +} + +type CompactErrorPayload struct { + TriggerMode string `json:"trigger_mode"` + Message string `json:"message"` +} + type ProviderFactory interface { Build(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) } @@ -52,11 +84,13 @@ type Service struct { toolManager tools.Manager // 工具管理器,统一工具 schema 暴露与执行入口。 providerFactory ProviderFactory // Provider 工厂接口,根据配置动态创建具体的 provider 实例。 contextBuilder agentcontext.Builder // 上下文构建器,负责组装 system prompt 与本轮发给模型的消息上下文。 - events chan RuntimeEvent // 事件通道,Runtime 在运行过程中产生的事件都通过该通道发送给 TUI 层消费和展示。 - runMu sync.Mutex // 运行互斥锁,保证同一时间只有一个 Run 在执行。 - activeRunToken uint64 // 当前活跃运行的令牌标识,用于标记正在执行的 Run 实例。 - nextRunToken uint64 // 下一个运行令牌的递增计数器,用于区分不同 Run 的生命周期。 - activeRunCancel context.CancelFunc // 当前活跃 Run 的取消函数。 + compactRunner contextcompact.Runner + events chan RuntimeEvent + operationMu sync.Mutex // 运行级互斥:串行化 Run 与 Compact,避免并发写同一会话。 + runMu sync.Mutex // 仅保护 activeRun* 字段的并发读写。 + activeRunToken uint64 // 当前活跃运行的令牌标识,用于标记正在执行的 Run 实例。 + nextRunToken uint64 // 下一个运行令牌的递增计数器,用于区分不同 Run 的生命周期。 + activeRunCancel context.CancelFunc // 当前活跃 Run 的取消函数。 } func NewWithFactory( @@ -82,11 +116,15 @@ func NewWithFactory( toolManager: toolManager, providerFactory: providerFactory, contextBuilder: contextBuilder, + compactRunner: contextcompact.NewRunner(), events: make(chan RuntimeEvent, 128), } } func (s *Service) Run(ctx context.Context, input UserInput) error { + s.operationMu.Lock() + defer s.operationMu.Unlock() + runCtx, cancel := context.WithCancel(ctx) runToken := s.startRun(cancel) defer func() { @@ -131,6 +169,11 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { return err } + session, _, err = s.runCompactForSession(ctx, input.RunID, session, cfg, contextcompact.ModeMicro, false) + if err != nil { + return s.handleRunError(ctx, input.RunID, session.ID, err) + } + builtContext, err := s.contextBuilder.Build(ctx, agentcontext.BuildInput{ Messages: session.Messages, Metadata: agentcontext.Metadata{ @@ -246,6 +289,39 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } } +func (s *Service) Compact(ctx context.Context, input CompactInput) (CompactResult, error) { + if err := ctx.Err(); err != nil { + return CompactResult{}, err + } + if strings.TrimSpace(input.SessionID) == "" { + return CompactResult{}, errors.New("runtime: compact session_id is empty") + } + + s.operationMu.Lock() + defer s.operationMu.Unlock() + + cfg := s.configManager.Get() + session, err := s.sessionStore.Load(ctx, input.SessionID) + if err != nil { + return CompactResult{}, err + } + + session, result, err := s.runCompactForSession(ctx, input.RunID, session, cfg, contextcompact.ModeManual, true) + if err != nil { + return CompactResult{}, err + } + + return CompactResult{ + Applied: result.Applied, + BeforeChars: result.Metrics.BeforeChars, + AfterChars: result.Metrics.AfterChars, + SavedRatio: result.Metrics.SavedRatio, + TriggerMode: result.Metrics.TriggerMode, + TranscriptID: result.TranscriptID, + TranscriptPath: result.TranscriptPath, + }, nil +} + func (s *Service) CancelActiveRun() bool { s.runMu.Lock() cancel := s.activeRunCancel @@ -364,6 +440,72 @@ func (s *Service) handleRunError(ctx context.Context, runID string, sessionID st return err } +func (s *Service) runCompactForSession( + ctx context.Context, + runID string, + session Session, + cfg config.Config, + mode contextcompact.Mode, + failOnError bool, +) (Session, contextcompact.Result, error) { + if s.compactRunner == nil { + return session, contextcompact.Result{}, nil + } + + originalMessages := append([]provider.Message(nil), session.Messages...) + s.emit(ctx, EventCompactStart, runID, session.ID, compactTriggerMode(mode)) + + result, err := s.compactRunner.Run(ctx, contextcompact.Input{ + Mode: mode, + SessionID: session.ID, + Workdir: cfg.Workdir, + Messages: session.Messages, + Config: cfg.Context.Compact, + }) + if err != nil { + s.emit(ctx, EventCompactError, runID, session.ID, CompactErrorPayload{ + TriggerMode: compactTriggerMode(mode), + Message: err.Error(), + }) + if failOnError { + return session, contextcompact.Result{}, err + } + return session, contextcompact.Result{}, nil + } + + if result.Applied { + session.Messages = append([]provider.Message(nil), result.Messages...) + session.UpdatedAt = time.Now() + if err := s.sessionStore.Save(ctx, &session); err != nil { + s.emit(ctx, EventCompactError, runID, session.ID, CompactErrorPayload{ + TriggerMode: compactTriggerMode(mode), + Message: err.Error(), + }) + session.Messages = originalMessages + if failOnError { + return session, contextcompact.Result{}, err + } + return session, contextcompact.Result{}, nil + } + } + + donePayload := CompactDonePayload{ + Applied: result.Applied, + BeforeChars: result.Metrics.BeforeChars, + AfterChars: result.Metrics.AfterChars, + SavedRatio: result.Metrics.SavedRatio, + TriggerMode: compactTriggerMode(mode), + TranscriptID: result.TranscriptID, + TranscriptPath: result.TranscriptPath, + } + s.emit(ctx, EventCompactDone, runID, session.ID, donePayload) + if mode == contextcompact.ModeMicro && result.Applied { + s.emit(ctx, EventMicroCompactApplied, runID, session.ID, donePayload) + } + + return session, result, nil +} + // isRetryableProviderError 判断 error 是否为可重试的 ProviderError。 func isRetryableProviderError(err error) bool { var pErr *provider.ProviderError @@ -450,6 +592,17 @@ func providerRetryBackoff(attempt int) time.Duration { return wait } +func compactTriggerMode(mode contextcompact.Mode) string { + switch mode { + case contextcompact.ModeMicro: + return CompactTriggerModeMicro + case contextcompact.ModeManual: + return CompactTriggerModeManual + default: + return string(mode) + } +} + func (s *Service) isRunCanceled(err error) bool { return errors.Is(err, context.Canceled) } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index f75a4cf8..04f7bb28 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -11,11 +11,27 @@ import ( "neo-code/internal/config" agentcontext "neo-code/internal/context" + contextcompact "neo-code/internal/context/compact" "neo-code/internal/provider" "neo-code/internal/provider/builtin" "neo-code/internal/tools" ) +func TestMain(m *testing.M) { + const key = config.OpenAIDefaultAPIKeyEnv + previousValue, hadPrevious := os.LookupEnv(key) + _ = os.Setenv(key, "test-key") + + exitCode := m.Run() + + if hadPrevious { + _ = os.Setenv(key, previousValue) + } else { + _ = os.Unsetenv(key) + } + os.Exit(exitCode) +} + type memoryStore struct { sessions map[string]Session saves int @@ -203,6 +219,23 @@ type stubToolManager struct { lastInput tools.ToolCallInput } +type stubCompactRunner struct { + runFn func(ctx context.Context, input contextcompact.Input) (contextcompact.Result, error) + calls []contextcompact.Input + result contextcompact.Result + err error +} + +func (r *stubCompactRunner) Run(ctx context.Context, input contextcompact.Input) (contextcompact.Result, error) { + cloned := input + cloned.Messages = append([]provider.Message(nil), input.Messages...) + r.calls = append(r.calls, cloned) + if r.runFn != nil { + return r.runFn(ctx, input) + } + return r.result, r.err +} + func (m *stubToolManager) ListAvailableSpecs(ctx context.Context, input tools.SpecListInput) ([]provider.ToolSpec, error) { m.listCalls++ if err := ctx.Err(); err != nil { @@ -1231,12 +1264,299 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { } } +func TestServiceRunAppliesMicroCompactBeforeContextBuild(t *testing.T) { + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Context.Compact.MicroEnabled = true + return nil + }); err != nil { + t.Fatalf("enable micro compact: %v", err) + } + + store := newMemoryStore() + session := newSession("existing") + session.ID = "session-micro" + session.Messages = []provider.Message{ + {Role: provider.RoleUser, Content: "history"}, + } + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "ok"}) + + builder := &stubContextBuilder{} + scripted := &scriptedProvider{ + responses: []provider.ChatResponse{ + { + Message: provider.Message{Role: provider.RoleAssistant, Content: "done"}, + }, + }, + } + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) + compactRunner := &stubCompactRunner{ + runFn: func(ctx context.Context, input contextcompact.Input) (contextcompact.Result, error) { + next := append([]provider.Message(nil), input.Messages...) + next = append(next, provider.Message{Role: provider.RoleAssistant, Content: "micro-summary"}) + return contextcompact.Result{ + Messages: next, + Applied: true, + Metrics: contextcompact.Metrics{ + BeforeChars: 10, + AfterChars: 6, + SavedRatio: 0.4, + TriggerMode: string(contextcompact.ModeMicro), + }, + TranscriptID: "transcript_1_session-micro", + TranscriptPath: "/tmp/transcript.jsonl", + }, nil + }, + } + service.compactRunner = compactRunner + + if err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-micro", + Content: "latest", + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(compactRunner.calls) == 0 || compactRunner.calls[0].Mode != contextcompact.ModeMicro { + t.Fatalf("expected micro compact call, got %+v", compactRunner.calls) + } + if len(builder.lastInput.Messages) == 0 || builder.lastInput.Messages[len(builder.lastInput.Messages)-1].Content != "micro-summary" { + t.Fatalf("expected compacted messages to be passed into context builder, got %+v", builder.lastInput.Messages) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{EventCompactStart, EventCompactDone, EventMicroCompactApplied, EventAgentDone}) + donePayload, ok := findEventPayload[CompactDonePayload](events, EventCompactDone) + if !ok || !donePayload.Applied || donePayload.TriggerMode != string(contextcompact.ModeMicro) { + t.Fatalf("expected compact_done payload with applied micro mode, got %+v", donePayload) + } +} + +func TestServiceRunMicroCompactFailureDoesNotBlockMainLoop(t *testing.T) { + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Context.Compact.MicroEnabled = true + return nil + }); err != nil { + t.Fatalf("enable micro compact: %v", err) + } + + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "ok"}) + + scripted := &scriptedProvider{ + responses: []provider.ChatResponse{ + { + Message: provider.Message{Role: provider.RoleAssistant, Content: "still works"}, + }, + }, + } + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) + service.compactRunner = &stubCompactRunner{err: errors.New("compact failed")} + + if err := service.Run(context.Background(), UserInput{RunID: "run-micro-fail", Content: "hello"}); err != nil { + t.Fatalf("expected run to continue when micro compact fails, got %v", err) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{EventCompactStart, EventCompactError, EventAgentDone}) + assertNoEventType(t, events, EventError) +} + +func TestServiceCompactManualAppliesAndPersists(t *testing.T) { + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := newSession("manual") + session.ID = "session-manual" + session.Messages = []provider.Message{ + {Role: provider.RoleUser, Content: "before"}, + } + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "ok"}) + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + service.compactRunner = &stubCompactRunner{ + result: contextcompact.Result{ + Messages: []provider.Message{ + {Role: provider.RoleAssistant, Content: "[compact_summary]\\ndone:\\n- ok\\n\\nin_progress:\\n- continue"}, + {Role: provider.RoleAssistant, Content: "latest"}, + }, + Applied: true, + Metrics: contextcompact.Metrics{ + BeforeChars: 80, + AfterChars: 30, + SavedRatio: 0.625, + TriggerMode: string(contextcompact.ModeManual), + }, + TranscriptID: "transcript_manual", + TranscriptPath: "/tmp/manual.jsonl", + }, + } + + result, err := service.Compact(context.Background(), CompactInput{ + SessionID: session.ID, + RunID: "run-manual", + }) + if err != nil { + t.Fatalf("Compact() error = %v", err) + } + if !result.Applied || result.BeforeChars != 80 || result.AfterChars != 30 { + t.Fatalf("unexpected compact result: %+v", result) + } + + saved, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load compacted session: %v", err) + } + if len(saved.Messages) != 2 || !strings.Contains(saved.Messages[0].Content, "compact_summary") { + t.Fatalf("expected persisted compacted messages, got %+v", saved.Messages) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{EventCompactStart, EventCompactDone}) + donePayload, ok := findEventPayload[CompactDonePayload](events, EventCompactDone) + if !ok || !donePayload.Applied || donePayload.TriggerMode != string(contextcompact.ModeManual) { + t.Fatalf("expected compact_done payload with applied manual mode, got %+v", donePayload) + } +} + +func TestServiceCompactManualFailureReturnsError(t *testing.T) { + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := newSession("manual-fail") + session.ID = "session-manual-fail" + session.Messages = []provider.Message{{Role: provider.RoleUser, Content: "before"}} + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "ok"}) + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + service.compactRunner = &stubCompactRunner{err: errors.New("manual compact failed")} + + _, err := service.Compact(context.Background(), CompactInput{ + SessionID: session.ID, + RunID: "run-manual-fail", + }) + if err == nil || !strings.Contains(err.Error(), "manual compact failed") { + t.Fatalf("expected compact failure, got %v", err) + } + + saved, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load original session: %v", err) + } + if len(saved.Messages) != 1 || saved.Messages[0].Content != "before" { + t.Fatalf("expected original session untouched, got %+v", saved.Messages) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{EventCompactStart, EventCompactError}) + assertNoEventType(t, events, EventCompactDone) +} + +func TestServiceSerializesRunAndCompact(t *testing.T) { + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := newSession("serialized") + session.ID = "session-serialized" + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "ok"}) + + providerStarted := make(chan struct{}) + unblockProvider := make(chan struct{}) + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { + select { + case <-providerStarted: + default: + close(providerStarted) + } + <-unblockProvider + return provider.ChatResponse{ + Message: provider.Message{ + Role: provider.RoleAssistant, + Content: "done", + }, + FinishReason: "stop", + }, nil + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) + compactEntered := make(chan struct{}, 1) + service.compactRunner = &stubCompactRunner{ + runFn: func(ctx context.Context, input contextcompact.Input) (contextcompact.Result, error) { + if input.Mode == contextcompact.ModeManual { + compactEntered <- struct{}{} + } + before := len(input.Messages) + return contextcompact.Result{ + Messages: input.Messages, + Applied: false, + Metrics: contextcompact.Metrics{ + BeforeChars: before, + AfterChars: before, + SavedRatio: 0, + TriggerMode: string(input.Mode), + }, + }, nil + }, + } + + runErrCh := make(chan error, 1) + go func() { + runErrCh <- service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-serialized", + Content: "hello", + }) + }() + + <-providerStarted + + compactErrCh := make(chan error, 1) + go func() { + _, err := service.Compact(context.Background(), CompactInput{ + SessionID: session.ID, + RunID: "compact-serialized", + }) + compactErrCh <- err + }() + + select { + case <-compactEntered: + t.Fatalf("expected compact to wait until run completes") + case <-time.After(120 * time.Millisecond): + } + + close(unblockProvider) + + if err := <-runErrCh; err != nil { + t.Fatalf("Run() error = %v", err) + } + if err := <-compactErrCh; err != nil { + t.Fatalf("Compact() error = %v", err) + } + + select { + case <-compactEntered: + default: + t.Fatalf("expected compact to execute after run finished") + } +} + func newRuntimeConfigManager(t *testing.T) *config.Manager { t.Helper() - restoreRuntimeEnv(t, config.OpenAIDefaultAPIKeyEnv) - if err := os.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key"); err != nil { - t.Fatalf("set env: %v", err) - } manager := config.NewManager(config.NewLoader(t.TempDir(), builtin.DefaultConfig())) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("load config: %v", err) @@ -1252,18 +1572,6 @@ func newRuntimeConfigManager(t *testing.T) *config.Manager { return manager } -func restoreRuntimeEnv(t *testing.T, key string) { - t.Helper() - value, ok := os.LookupEnv(key) - t.Cleanup(func() { - if !ok { - _ = os.Unsetenv(key) - return - } - _ = os.Setenv(key, value) - }) -} - func onlySession(t *testing.T, store *memoryStore) Session { t.Helper() if len(store.sessions) != 1 { @@ -1312,6 +1620,21 @@ func assertNoEventType(t *testing.T, events []RuntimeEvent, unexpected EventType } } +func findEventPayload[T any](events []RuntimeEvent, kind EventType) (T, bool) { + var zero T + for _, event := range events { + if event.Type != kind { + continue + } + payload, ok := event.Payload.(T) + if !ok { + return zero, false + } + return payload, true + } + return zero, false +} + func assertEventsRunID(t *testing.T, events []RuntimeEvent, runID string) { t.Helper() for _, event := range events { @@ -1411,3 +1734,39 @@ func TestProviderRetryBackoff(t *testing.T) { }) } } + +func TestCompactTriggerMode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mode contextcompact.Mode + want string + }{ + { + name: "micro mode", + mode: contextcompact.ModeMicro, + want: CompactTriggerModeMicro, + }, + { + name: "manual mode", + mode: contextcompact.ModeManual, + want: CompactTriggerModeManual, + }, + { + name: "unknown mode fallback", + mode: contextcompact.Mode("custom"), + want: "custom", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := compactTriggerMode(tt.mode); got != tt.want { + t.Fatalf("compactTriggerMode(%q) = %q, want %q", tt.mode, got, tt.want) + } + }) + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index fa16e18a..b269522b 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -17,6 +17,7 @@ const ( slashCommandHelp = "/help" slashCommandExit = "/exit" slashCommandClear = "/clear" + slashCommandCompact = "/compact" slashCommandStatus = "/status" slashCommandProvider = "/provider" slashCommandModelPick = "/model" @@ -24,6 +25,7 @@ const ( slashUsageHelp = "/help" slashUsageExit = "/exit" slashUsageClear = "/clear" + slashUsageCompact = "/compact" slashUsageStatus = "/status" slashUsageProvider = "/provider" slashUsageModel = "/model" @@ -58,6 +60,7 @@ const ( statusApplyingCommand = "Applying local command" statusRunningCommand = "Running command" statusCommandDone = "Command finished" + statusCompacting = "Compacting context" statusChooseProvider = "Choose a provider" statusChooseModel = "Choose a model" @@ -95,6 +98,7 @@ type statusSnapshot struct { ActiveSessionID string ActiveSessionTitle string IsAgentRunning bool + IsCompacting bool CurrentProvider string CurrentModel string CurrentWorkdir string @@ -108,6 +112,7 @@ type statusSnapshot struct { var builtinSlashCommands = []slashCommand{ {Usage: slashUsageHelp, Description: "Show slash command help"}, {Usage: slashUsageClear, Description: "Clear the current draft transcript"}, + {Usage: slashUsageCompact, Description: "Compact the current session context"}, {Usage: slashUsageStatus, Description: "Show current session and agent status"}, {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, @@ -336,7 +341,7 @@ func executeStatusCommand(snapshot statusSnapshot) string { sessionTitle = draftSessionTitle } running := "no" - if snapshot.IsAgentRunning { + if snapshot.IsAgentRunning || snapshot.IsCompacting { running = "yes" } currentTool := snapshot.CurrentTool diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index 903bdaa9..23f19ef2 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -25,6 +25,7 @@ func TestExecuteLocalCommand(t *testing.T) { for _, want := range []string{ slashUsageHelp, slashUsageClear, + slashUsageCompact, slashUsageStatus, slashUsageProvider, slashUsageModel, @@ -303,3 +304,18 @@ func TestExecuteStatusCommandSnapshot(t *testing.T) { } } } + +func TestExecuteStatusCommandTreatsCompactingAsRunning(t *testing.T) { + notice := executeStatusCommand(statusSnapshot{ + ActiveSessionTitle: draftSessionTitle, + IsCompacting: true, + CurrentProvider: "openai", + CurrentModel: "gpt-5.4", + CurrentWorkdir: `D:\repo`, + FocusLabel: focusLabelComposer, + PickerLabel: "none", + }) + if !strings.Contains(notice, "Running: yes") { + t.Fatalf("expected compacting state to be reported as running, got %q", notice) + } +} diff --git a/internal/tui/state.go b/internal/tui/state.go index 2bd56b35..f2191225 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -36,6 +36,7 @@ type UIState struct { ActiveSessionTitle string InputText string IsAgentRunning bool + IsCompacting bool StreamingReply bool CurrentTool string ExecutionError string diff --git a/internal/tui/update.go b/internal/tui/update.go index 5d8d82a9..016ec9a3 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -27,6 +27,9 @@ type modelCatalogRefreshMsg struct { models []provider.ModelDescriptor err error } +type compactFinishedMsg struct { + err error +} type localCommandResultMsg struct { notice string err error @@ -45,7 +48,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var spinCmd tea.Cmd a.spinner, spinCmd = a.spinner.Update(msg) - if a.state.IsAgentRunning { + if a.isBusy() { cmds = append(cmds, spinCmd) } @@ -66,6 +69,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) case RuntimeClosedMsg: a.state.IsAgentRunning = false + a.state.IsCompacting = false if strings.TrimSpace(a.state.StatusText) == "" { a.state.StatusText = statusRuntimeClosed } @@ -103,6 +107,27 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.syncConfigState(cfg) a.selectCurrentModel(cfg.CurrentModel) return a, tea.Batch(cmds...) + case compactFinishedMsg: + // compact feedback should primarily come from runtime compact_done/error events. + a.state.IsCompacting = false + if typed.err != nil && strings.TrimSpace(a.state.ExecutionError) == "" { + a.state.ExecutionError = typed.err.Error() + a.state.StatusText = typed.err.Error() + } + if err := a.refreshSessions(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendInlineMessage(roleError, err.Error()) + } + if err := a.refreshMessages(); err != nil && strings.TrimSpace(a.state.ActiveSessionID) != "" { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendInlineMessage(roleError, err.Error()) + } + a.syncActiveSessionTitle() + a.rebuildTranscript() + a.transcript.GotoBottom() + return a, tea.Batch(cmds...) case localCommandResultMsg: if typed.err != nil { a.state.ExecutionError = typed.err.Error() @@ -194,7 +219,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.applyFocus() return a, tea.Batch(cmds...) } - if key.Matches(typed, a.keys.NewSession) && !a.state.IsAgentRunning { + if key.Matches(typed, a.keys.NewSession) && !a.isBusy() { a.startDraftSession() return a, tea.Batch(cmds...) } @@ -230,7 +255,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (tea.Model, tea.Cmd) { if key.Matches(typed, a.keys.Send) { input := strings.TrimSpace(a.input.Value()) - if input == "" || a.state.IsAgentRunning { + if input == "" || a.isBusy() { return a, tea.Batch(cmds...) } @@ -292,6 +317,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.activities = nil a.state.IsAgentRunning = true + a.state.IsCompacting = false a.state.StreamingReply = false a.state.ExecutionError = "" a.state.StatusText = statusThinking @@ -525,6 +551,50 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { a.state.StatusText = statusThinking a.appendActivity("provider", "Retrying provider call", payload, false) } + case agentruntime.EventCompactDone: + payload, ok := event.Payload.(agentruntime.CompactDonePayload) + if !ok { + return transcriptDirty + } + // Skip passive micro checks that did not rewrite context to avoid transcript noise. + if payload.TriggerMode == agentruntime.CompactTriggerModeMicro && !payload.Applied { + return transcriptDirty + } + a.state.ExecutionError = "" + a.state.StatusText = fmt.Sprintf( + "Compact(%s) saved %.1f%% context", + payload.TriggerMode, + payload.SavedRatio*100, + ) + a.appendInlineMessage( + roleSystem, + fmt.Sprintf( + "[System] Compact(%s) %s (before=%d, after=%d, saved=%.1f%%, transcript=%s)", + payload.TriggerMode, + map[bool]string{true: "applied", false: "checked"}[payload.Applied], + payload.BeforeChars, + payload.AfterChars, + payload.SavedRatio*100, + payload.TranscriptPath, + ), + ) + transcriptDirty = true + case agentruntime.EventCompactError: + payload, ok := event.Payload.(agentruntime.CompactErrorPayload) + if !ok { + return transcriptDirty + } + if payload.TriggerMode == agentruntime.CompactTriggerModeMicro { + // Micro compact failure is non-blocking for the main loop, keep it as a lightweight notice. + a.appendInlineMessage(roleEvent, fmt.Sprintf("Compact(micro) skipped: %s", payload.Message)) + transcriptDirty = true + return transcriptDirty + } + message := fmt.Sprintf("Compact(%s) failed: %s", payload.TriggerMode, payload.Message) + a.state.ExecutionError = message + a.state.StatusText = message + a.appendInlineMessage(roleError, message) + transcriptDirty = true } return transcriptDirty @@ -717,7 +787,7 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { a.rebuildTranscript() return } - if a.transcript.AtBottom() || a.state.IsAgentRunning { + if a.transcript.AtBottom() || a.isBusy() { a.transcript.GotoBottom() } } @@ -763,13 +833,13 @@ func (a *App) rebuildTranscript() { } a.transcript.SetContent(strings.Join(blocks, "\n\n")) - if atBottom || a.state.IsAgentRunning { + if atBottom || a.isBusy() { a.transcript.GotoBottom() } } func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { - command, _ := splitFirstWord(strings.ToLower(strings.TrimSpace(input))) + command, rest := splitFirstWord(strings.ToLower(strings.TrimSpace(input))) switch command { case slashCommandExit: return true, tea.Quit @@ -777,6 +847,29 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { a.startDraftSession() a.state.StatusText = "[System] Cleared current draft/history." return true, nil + case slashCommandCompact: + if strings.TrimSpace(rest) != "" { + errText := fmt.Sprintf("usage: %s", slashUsageCompact) + a.state.ExecutionError = errText + a.state.StatusText = errText + a.appendInlineMessage(roleError, errText) + a.rebuildTranscript() + return true, nil + } + if a.isBusy() { + errText := "compact is already running, please wait" + a.state.ExecutionError = errText + a.state.StatusText = errText + a.appendInlineMessage(roleError, errText) + a.rebuildTranscript() + return true, nil + } + a.state.IsCompacting = true + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.StatusText = statusCompacting + a.state.ExecutionError = "" + return true, runCompact(a.runtime, a.state.ActiveSessionID) default: return false, nil } @@ -795,6 +888,7 @@ func (a App) currentStatusSnapshot() statusSnapshot { ActiveSessionID: a.state.ActiveSessionID, ActiveSessionTitle: a.state.ActiveSessionTitle, IsAgentRunning: a.state.IsAgentRunning, + IsCompacting: a.state.IsCompacting, CurrentProvider: a.state.CurrentProvider, CurrentModel: a.state.CurrentModel, CurrentWorkdir: a.state.CurrentWorkdir, @@ -811,6 +905,7 @@ func (a *App) startDraftSession() { a.state.ActiveSessionTitle = draftSessionTitle a.activeMessages = nil a.activities = nil + a.state.IsCompacting = false a.state.StatusText = statusDraft a.state.ExecutionError = "" a.state.CurrentTool = "" @@ -848,3 +943,14 @@ func runAgent(runtime agentruntime.Runtime, sessionID string, content string) te return runFinishedMsg{err: err} } } + +func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { + return func() tea.Msg { + _, err := runtime.Compact(context.Background(), agentruntime.CompactInput{SessionID: sessionID}) + return compactFinishedMsg{err: err} + } +} + +func (a App) isBusy() bool { + return a.state.IsAgentRunning || a.state.IsCompacting +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 6942e988..46d4a315 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -19,15 +19,18 @@ import ( ) type stubRuntime struct { - runInputs []agentruntime.UserInput - events chan agentruntime.RuntimeEvent - sessions []agentruntime.SessionSummary - loads map[string]agentruntime.Session - runErr error - listErr error - loadErr error - cancelCalls int - cancelResult bool + runInputs []agentruntime.UserInput + compactInputs []agentruntime.CompactInput + events chan agentruntime.RuntimeEvent + sessions []agentruntime.SessionSummary + loads map[string]agentruntime.Session + runErr error + compactErr error + compactResult agentruntime.CompactResult + listErr error + loadErr error + cancelCalls int + cancelResult bool } func newStubRuntime() *stubRuntime { @@ -42,6 +45,11 @@ func (r *stubRuntime) Run(ctx context.Context, input agentruntime.UserInput) err return r.runErr } +func (r *stubRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + r.compactInputs = append(r.compactInputs, input) + return r.compactResult, r.compactErr +} + func (r *stubRuntime) Events() <-chan agentruntime.RuntimeEvent { return r.events } @@ -1191,6 +1199,93 @@ func TestAppHandleRuntimeEventAdditionalBranches(t *testing.T) { } }, }, + { + name: "compact done event emits lightweight notice", + event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventCompactDone, + SessionID: "s1", + Payload: agentruntime.CompactDonePayload{ + Applied: true, + BeforeChars: 100, + AfterChars: 60, + SavedRatio: 0.4, + TriggerMode: agentruntime.CompactTriggerModeManual, + TranscriptPath: "/tmp/t.jsonl", + }, + }, + assert: func(t *testing.T, app App) { + t.Helper() + if !strings.Contains(app.state.StatusText, "Compact(manual)") { + t.Fatalf("expected compact status text, got %q", app.state.StatusText) + } + if len(app.activeMessages) == 0 || !strings.Contains(app.activeMessages[len(app.activeMessages)-1].Content, "Compact(manual)") { + t.Fatalf("expected compact inline notice, got %+v", app.activeMessages) + } + }, + }, + { + name: "compact done ignores passive micro checks", + setup: func(app *App) { + app.state.StatusText = "unchanged" + }, + event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventCompactDone, + SessionID: "s1", + Payload: agentruntime.CompactDonePayload{ + Applied: false, + TriggerMode: agentruntime.CompactTriggerModeMicro, + }, + }, + assert: func(t *testing.T, app App) { + t.Helper() + if app.state.StatusText != "unchanged" { + t.Fatalf("expected status unchanged for passive micro compact, got %q", app.state.StatusText) + } + }, + }, + { + name: "compact error event is surfaced", + event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventCompactError, + SessionID: "s1", + Payload: agentruntime.CompactErrorPayload{ + TriggerMode: agentruntime.CompactTriggerModeManual, + Message: "disk full", + }, + }, + assert: func(t *testing.T, app App) { + t.Helper() + if !strings.Contains(app.state.ExecutionError, "Compact(manual) failed: disk full") { + t.Fatalf("expected compact error in state, got %q", app.state.ExecutionError) + } + }, + }, + { + name: "micro compact error remains non-blocking notice", + setup: func(app *App) { + app.state.StatusText = statusThinking + }, + event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventCompactError, + SessionID: "s1", + Payload: agentruntime.CompactErrorPayload{ + TriggerMode: agentruntime.CompactTriggerModeMicro, + Message: "tmp write failed", + }, + }, + assert: func(t *testing.T, app App) { + t.Helper() + if app.state.ExecutionError != "" { + t.Fatalf("expected no execution error for micro compact failure, got %q", app.state.ExecutionError) + } + if app.state.StatusText != statusThinking { + t.Fatalf("expected status unchanged for micro compact failure, got %q", app.state.StatusText) + } + if len(app.activeMessages) == 0 || !strings.Contains(app.activeMessages[len(app.activeMessages)-1].Content, "Compact(micro) skipped") { + t.Fatalf("expected lightweight micro compact notice, got %+v", app.activeMessages) + } + }, + }, } for _, tt := range tests { @@ -1263,6 +1358,50 @@ func TestImmediateSlashCommandsAndLayoutBranches(t *testing.T) { t.Fatalf("expected /clear to reset draft state") } + runtime.compactResult = agentruntime.CompactResult{ + Applied: true, + BeforeChars: 100, + AfterChars: 40, + SavedRatio: 0.6, + TranscriptPath: "/tmp/transcript.jsonl", + } + app.state.ActiveSessionID = "session-compact" + handled, cmd = app.handleImmediateSlashCommand("/compact") + if !handled || cmd == nil { + t.Fatalf("expected /compact to trigger compact cmd") + } + if app.state.StatusText != statusCompacting { + t.Fatalf("expected compact status %q, got %q", statusCompacting, app.state.StatusText) + } + if !app.state.IsCompacting { + t.Fatalf("expected /compact to mark UI as compacting") + } + msgs := collectTeaMessages(cmd) + if len(msgs) != 1 { + t.Fatalf("expected one compact command message, got %d", len(msgs)) + } + if _, ok := msgs[0].(compactFinishedMsg); !ok { + t.Fatalf("expected compact finished msg, got %T", msgs[0]) + } + if len(runtime.compactInputs) != 1 || runtime.compactInputs[0].SessionID != "session-compact" { + t.Fatalf("expected runtime compact call with active session, got %+v", runtime.compactInputs) + } + handled, cmd = app.handleImmediateSlashCommand("/compact") + if !handled || cmd != nil { + t.Fatalf("expected re-entrant /compact to be rejected while compacting") + } + if !strings.Contains(strings.ToLower(app.state.StatusText), "compact is already running") { + t.Fatalf("expected compact busy hint, got %q", app.state.StatusText) + } + + handled, cmd = app.handleImmediateSlashCommand("/compact now") + if !handled || cmd != nil { + t.Fatalf("expected /compact with args to be handled locally with usage error") + } + if !strings.Contains(app.state.StatusText, "usage: /compact") { + t.Fatalf("expected /compact usage hint, got %q", app.state.StatusText) + } + handled, cmd = app.handleImmediateSlashCommand("/exit") if !handled || cmd == nil { t.Fatalf("expected /exit to return a quit cmd") @@ -1293,6 +1432,56 @@ func TestImmediateSlashCommandsAndLayoutBranches(t *testing.T) { } } +func TestCompactBusyBlocksSendAndNewSession(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.state.IsCompacting = true + app.state.ActiveSessionID = "session-existing" + app.state.ActiveSessionTitle = "Existing" + app.input.SetValue("hello") + app.state.InputText = "hello" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + _ = collectTeaMessages(cmd) + if len(runtime.runInputs) != 0 { + t.Fatalf("expected send to be blocked while compacting, got %+v", runtime.runInputs) + } + if app.state.InputText != "hello" { + t.Fatalf("expected input unchanged while compacting, got %q", app.state.InputText) + } + + model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) + app = model.(App) + _ = collectTeaMessages(cmd) + if app.state.ActiveSessionID != "session-existing" { + t.Fatalf("expected new-session shortcut to be blocked while compacting") + } +} + +func TestCompactFinishedMsgClearsBusyState(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.state.IsCompacting = true + model, cmd := app.Update(compactFinishedMsg{err: nil}) + app = model.(App) + _ = collectTeaMessages(cmd) + + if app.state.IsCompacting { + t.Fatalf("expected compact finished message to clear busy compacting state") + } +} + func TestAdditionalRenderingAndToolChunkBranches(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() diff --git a/internal/tui/view.go b/internal/tui/view.go index 6b31ae7a..54d127be 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -44,7 +44,7 @@ func (a App) View() string { func (a App) renderHeader(width int) string { status := compactStatusText(a.state.StatusText, max(18, width/3)) - if a.state.IsAgentRunning { + if a.isBusy() { status = a.spinner.View() + " " + fallback(status, statusRunning) } @@ -170,7 +170,7 @@ func (a App) renderPrompt(width int) string { box = a.styles.inputBoxFocused } - // 计算边框和内边距占用的空间 + // 计算边框和内边距占用的空间。 boxWidth := a.composerBoxWidth(width) return box.Width(boxWidth).Render(a.input.View()) @@ -342,7 +342,7 @@ func (a App) commandMenuHeight(width int) int { func (a App) renderHelp(width int) string { a.help.ShowAll = a.state.ShowHelp helpContent := a.help.View(a.keys) - // 确保帮助视图填充整个宽度,避免边框断裂 + // 确保帮助视图填充整个宽度,避免边框断裂。 return a.styles.footer.Width(width).Render(helpContent) } @@ -390,7 +390,7 @@ func (a App) statusBadge(text string) string { return a.styles.badgeError.Render(text) case strings.Contains(lower, "cancel"): return a.styles.badgeWarning.Render(text) - case a.state.IsAgentRunning || strings.Contains(lower, "running") || strings.Contains(lower, "thinking"): + case a.isBusy() || strings.Contains(lower, "running") || strings.Contains(lower, "thinking"): return a.styles.badgeWarning.Render(text) default: return a.styles.badgeSuccess.Render(text) From 2fba03be4381d31871bf9a3e98efc7a9c0559b9f Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Fri, 3 Apr 2026 11:14:32 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(config,runtime):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=20compact=20=E9=85=8D=E7=BD=AE=E6=A8=A1=E5=9E=8B=E4=B8=8E?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 context.compact 配置结构、默认值、校验与克隆逻辑\n- 支持 compact 配置的 YAML 持久化与兼容加载(含 micro_enabled 缺省回退)\n- 补充 compact 配置相关单元测试与 round-trip 验证\n- 扩展 runtime compact 事件类型与触发模式常量\n- 忽略 compact transcript 目录,避免本地产物入库 --- .gitignore | 1 + internal/config/config_test.go | 178 +++++++++++++++++++++++++++++++++ internal/config/loader.go | 72 +++++++++++-- internal/config/model.go | 123 ++++++++++++++++++++++- internal/runtime/events.go | 15 +++ 5 files changed, 375 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index df09beca..8c4a7761 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ config.local.yaml # Local data data/ .cache/ +.neocode/projects/**/.transcripts/ # Editor/IDE .idea/ diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cc0d4ead..6e515365 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -829,6 +829,184 @@ func TestConstructorsRejectMissingDependencies(t *testing.T) { }) } +func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) { + tempDir := t.TempDir() + loader := NewLoader(tempDir, testDefaultConfig()) + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + compactCfg := cfg.Context.Compact + if !compactCfg.MicroEnabled { + t.Fatalf("expected micro_enabled default true") + } + if compactCfg.ToolResultKeepRecent != DefaultCompactToolResultKeepRecent { + t.Fatalf("expected tool_result_keep_recent=%d, got %d", DefaultCompactToolResultKeepRecent, compactCfg.ToolResultKeepRecent) + } + if compactCfg.ToolResultPlaceholderMinChars != DefaultCompactPlaceholderMinChars { + t.Fatalf("expected tool_result_placeholder_min_chars=%d, got %d", DefaultCompactPlaceholderMinChars, compactCfg.ToolResultPlaceholderMinChars) + } + if compactCfg.ManualStrategy != CompactManualStrategyKeepRecent { + t.Fatalf("expected manual strategy %q, got %q", CompactManualStrategyKeepRecent, compactCfg.ManualStrategy) + } + if compactCfg.ManualKeepRecentSpans != DefaultCompactManualKeepRecentSpans { + t.Fatalf("expected manual_keep_recent_spans=%d, got %d", DefaultCompactManualKeepRecentSpans, compactCfg.ManualKeepRecentSpans) + } + if compactCfg.MaxSummaryChars != DefaultCompactMaxSummaryChars { + t.Fatalf("expected max_summary_chars=%d, got %d", DefaultCompactMaxSummaryChars, compactCfg.MaxSummaryChars) + } + + if err := loader.Save(context.Background(), cfg); err != nil { + t.Fatalf("Save() error = %v", err) + } + reloaded, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + + if reloaded.Context.Compact.ManualStrategy != CompactManualStrategyKeepRecent { + t.Fatalf("expected manual strategy to persist, got %q", reloaded.Context.Compact.ManualStrategy) + } +} + +func TestCompactConfigMicroEnabledFalsePersistsAcrossReload(t *testing.T) { + tempDir := t.TempDir() + loader := NewLoader(tempDir, testDefaultConfig()) + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + cfg.Context.Compact.MicroEnabled = false + + if err := loader.Save(context.Background(), cfg); err != nil { + t.Fatalf("Save() error = %v", err) + } + reloaded, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + if reloaded.Context.Compact.MicroEnabled { + t.Fatalf("expected micro_enabled to persist as false after reload") + } +} + +func TestCompactConfigMissingMicroEnabledFallsBackToDefaultTrue(t *testing.T) { + tempDir := t.TempDir() + loader := NewLoader(tempDir, testDefaultConfig()) + if err := os.MkdirAll(loader.BaseDir(), 0o755); err != nil { + t.Fatalf("create base dir: %v", err) + } + + legacyConfig := []byte(strings.Join([]string{ + "selected_provider: openai", + "current_model: gpt-4.1", + "workdir: " + filepath.ToSlash(tempDir), + "shell: powershell", + "", + }, "\n")) + if err := os.WriteFile(loader.ConfigPath(), legacyConfig, 0o644); err != nil { + t.Fatalf("write legacy config: %v", err) + } + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if !cfg.Context.Compact.MicroEnabled { + t.Fatalf("expected missing micro_enabled to fallback to default true") + } +} + +func TestCompactConfigValidateFailures(t *testing.T) { + tests := []struct { + name string + compact CompactConfig + expectErr string + }{ + { + name: "invalid keep recent", + compact: CompactConfig{ + ToolResultKeepRecent: 0, + ToolResultPlaceholderMinChars: 100, + ManualStrategy: CompactManualStrategyKeepRecent, + ManualKeepRecentSpans: 6, + MaxSummaryChars: 1200, + }, + expectErr: "tool_result_keep_recent", + }, + { + name: "invalid placeholder min chars", + compact: CompactConfig{ + ToolResultKeepRecent: 3, + ToolResultPlaceholderMinChars: 0, + ManualStrategy: CompactManualStrategyKeepRecent, + ManualKeepRecentSpans: 6, + MaxSummaryChars: 1200, + }, + expectErr: "tool_result_placeholder_min_chars", + }, + { + name: "invalid manual strategy", + compact: CompactConfig{ + ToolResultKeepRecent: 3, + ToolResultPlaceholderMinChars: 100, + ManualStrategy: "unsupported", + ManualKeepRecentSpans: 6, + MaxSummaryChars: 1200, + }, + expectErr: "manual_strategy", + }, + { + name: "invalid manual keep spans", + compact: CompactConfig{ + ToolResultKeepRecent: 3, + ToolResultPlaceholderMinChars: 100, + ManualStrategy: CompactManualStrategyKeepRecent, + ManualKeepRecentSpans: 0, + MaxSummaryChars: 1200, + }, + expectErr: "manual_keep_recent_spans", + }, + { + name: "invalid summary chars", + compact: CompactConfig{ + ToolResultKeepRecent: 3, + ToolResultPlaceholderMinChars: 100, + ManualStrategy: CompactManualStrategyKeepRecent, + ManualKeepRecentSpans: 6, + MaxSummaryChars: 0, + }, + expectErr: "max_summary_chars", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + err := tt.compact.Validate() + if err == nil || !strings.Contains(err.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + }) + } +} + +func TestCompactConfigValidateSupportsFullReplace(t *testing.T) { + err := (CompactConfig{ + ToolResultKeepRecent: 3, + ToolResultPlaceholderMinChars: 100, + ManualStrategy: CompactManualStrategyFullReplace, + ManualKeepRecentSpans: 6, + MaxSummaryChars: 1200, + }).Validate() + if err != nil { + t.Fatalf("expected full_replace strategy to be valid, got %v", err) + } +} + func restoreEnv(t *testing.T, key string) { t.Helper() value, ok := os.LookupEnv(key) diff --git a/internal/config/loader.go b/internal/config/loader.go index c3563828..7ee053b1 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -22,13 +22,27 @@ type Loader struct { } type persistedConfig struct { - SelectedProvider string `yaml:"selected_provider"` - CurrentModel string `yaml:"current_model"` - Workdir string `yaml:"workdir"` - Shell string `yaml:"shell"` - MaxLoops int `yaml:"max_loops,omitempty"` - ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` - Tools ToolsConfig `yaml:"tools,omitempty"` + SelectedProvider string `yaml:"selected_provider"` + CurrentModel string `yaml:"current_model"` + Workdir string `yaml:"workdir"` + Shell string `yaml:"shell"` + MaxLoops int `yaml:"max_loops,omitempty"` + ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Context persistedContextConfig `yaml:"context,omitempty"` + Tools ToolsConfig `yaml:"tools,omitempty"` +} + +type persistedContextConfig struct { + Compact persistedCompactConfig `yaml:"compact,omitempty"` +} + +type persistedCompactConfig struct { + MicroEnabled *bool `yaml:"micro_enabled,omitempty"` + ToolResultKeepRecent int `yaml:"tool_result_keep_recent,omitempty"` + ToolResultPlaceholderMinChars int `yaml:"tool_result_placeholder_min_chars,omitempty"` + ManualStrategy string `yaml:"manual_strategy,omitempty"` + ManualKeepRecentSpans int `yaml:"manual_keep_recent_spans,omitempty"` + MaxSummaryChars int `yaml:"max_summary_chars,omitempty"` } func NewLoader(baseDir string, defaults *Config) *Loader { @@ -84,7 +98,7 @@ func (l *Loader) Load(ctx context.Context) (*Config, error) { return nil, fmt.Errorf("config: read config file: %w", err) } - cfg, err := parseConfig(data) + cfg, err := parseConfigWithContextDefaults(data, l.defaults.Context) if err != nil { return nil, fmt.Errorf("config: parse config file: %w", err) } @@ -140,14 +154,18 @@ func defaultBaseDir() string { } func parseConfig(data []byte) (*Config, error) { + return parseConfigWithContextDefaults(data, Default().Context) +} + +func parseConfigWithContextDefaults(data []byte, contextDefaults ContextConfig) (*Config, error) { if len(bytes.TrimSpace(data)) == 0 { return &Config{}, nil } - return parseCurrentConfig(data) + return parseCurrentConfig(data, contextDefaults) } -func parseCurrentConfig(data []byte) (*Config, error) { +func parseCurrentConfig(data []byte, contextDefaults ContextConfig) (*Config, error) { var file persistedConfig if err := yaml.Unmarshal(data, &file); err != nil { return nil, err @@ -160,6 +178,7 @@ func parseCurrentConfig(data []byte) (*Config, error) { Shell: strings.TrimSpace(file.Shell), MaxLoops: file.MaxLoops, ToolTimeoutSec: file.ToolTimeoutSec, + Context: fromPersistedContextConfig(file.Context, contextDefaults), Tools: file.Tools, } @@ -174,6 +193,7 @@ func marshalPersistedConfig(snapshot Config) ([]byte, error) { Shell: snapshot.Shell, MaxLoops: snapshot.MaxLoops, ToolTimeoutSec: snapshot.ToolTimeoutSec, + Context: newPersistedContextConfig(snapshot.Context), Tools: snapshot.Tools, } @@ -187,6 +207,38 @@ func marshalPersistedConfig(snapshot Config) ([]byte, error) { return data, nil } +func newPersistedContextConfig(cfg ContextConfig) persistedContextConfig { + microEnabled := cfg.Compact.MicroEnabled + return persistedContextConfig{ + Compact: persistedCompactConfig{ + MicroEnabled: µEnabled, + ToolResultKeepRecent: cfg.Compact.ToolResultKeepRecent, + ToolResultPlaceholderMinChars: cfg.Compact.ToolResultPlaceholderMinChars, + ManualStrategy: cfg.Compact.ManualStrategy, + ManualKeepRecentSpans: cfg.Compact.ManualKeepRecentSpans, + MaxSummaryChars: cfg.Compact.MaxSummaryChars, + }, + } +} + +func fromPersistedContextConfig(file persistedContextConfig, defaults ContextConfig) ContextConfig { + out := ContextConfig{ + Compact: CompactConfig{ + ToolResultKeepRecent: file.Compact.ToolResultKeepRecent, + ToolResultPlaceholderMinChars: file.Compact.ToolResultPlaceholderMinChars, + ManualStrategy: strings.TrimSpace(file.Compact.ManualStrategy), + ManualKeepRecentSpans: file.Compact.ManualKeepRecentSpans, + MaxSummaryChars: file.Compact.MaxSummaryChars, + }, + } + if file.Compact.MicroEnabled != nil { + out.Compact.MicroEnabled = *file.Compact.MicroEnabled + } else { + out.Compact.MicroEnabled = defaults.Compact.MicroEnabled + } + return out +} + func persistedConfigDiffers(data []byte, cfg Config) (bool, error) { canonical, err := marshalPersistedConfig(cfg) if err != nil { diff --git a/internal/config/model.go b/internal/config/model.go index f8c2bd52..4341e49c 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -11,10 +11,19 @@ import ( ) const ( - DefaultWorkdir = "." - DefaultMaxLoops = 8 - DefaultToolTimeoutSec = 20 - DefaultWebFetchMaxResponseBytes int64 = 256 * 1024 + DefaultWorkdir = "." + DefaultMaxLoops = 8 + DefaultToolTimeoutSec = 20 + DefaultWebFetchMaxResponseBytes int64 = 256 * 1024 + DefaultCompactToolResultKeepRecent = 3 + DefaultCompactPlaceholderMinChars = 100 + DefaultCompactManualKeepRecentSpans = 6 + DefaultCompactMaxSummaryChars = 1200 +) + +const ( + CompactManualStrategyKeepRecent = "keep_recent" + CompactManualStrategyFullReplace = "full_replace" ) var defaultWebFetchSupportedContentTypes = []string{ @@ -34,6 +43,7 @@ type Config struct { Shell string `yaml:"shell"` MaxLoops int `yaml:"max_loops,omitempty"` ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Context ContextConfig `yaml:"context,omitempty"` Tools ToolsConfig `yaml:"tools,omitempty"` } @@ -54,6 +64,19 @@ type ToolsConfig struct { WebFetch WebFetchConfig `yaml:"webfetch,omitempty"` } +type ContextConfig struct { + Compact CompactConfig `yaml:"compact,omitempty"` +} + +type CompactConfig struct { + MicroEnabled bool `yaml:"micro_enabled,omitempty"` + ToolResultKeepRecent int `yaml:"tool_result_keep_recent,omitempty"` + ToolResultPlaceholderMinChars int `yaml:"tool_result_placeholder_min_chars,omitempty"` + ManualStrategy string `yaml:"manual_strategy,omitempty"` + ManualKeepRecentSpans int `yaml:"manual_keep_recent_spans,omitempty"` + MaxSummaryChars int `yaml:"max_summary_chars,omitempty"` +} + type WebFetchConfig struct { MaxResponseBytes int64 `yaml:"max_response_bytes,omitempty"` SupportedContentTypes []string `yaml:"supported_content_types,omitempty"` @@ -69,6 +92,7 @@ func Default() *Config { Shell: defaultShell(), MaxLoops: DefaultMaxLoops, ToolTimeoutSec: DefaultToolTimeoutSec, + Context: defaultContextConfig(), Tools: ToolsConfig{ WebFetch: defaultWebFetchConfig(), }, @@ -82,6 +106,7 @@ func (c *Config) Clone() Config { clone := *c clone.Providers = cloneProviders(c.Providers) + clone.Context = c.Context.Clone() clone.Tools = c.Tools.Clone() return clone } @@ -126,6 +151,7 @@ func (c *Config) ApplyDefaultsFrom(defaults Config) { if c.ToolTimeoutSec <= 0 { c.ToolTimeoutSec = defaults.ToolTimeoutSec } + c.Context.ApplyDefaults(defaults.Context) c.Tools.ApplyDefaults(defaults.Tools) c.Workdir = normalizeWorkdir(c.Workdir) @@ -189,6 +215,9 @@ func (c *Config) Validate() error { if err := c.Tools.Validate(); err != nil { return fmt.Errorf("config: tools: %w", err) } + if err := c.Context.Validate(); err != nil { + return fmt.Errorf("config: context: %w", err) + } return nil } @@ -305,12 +334,35 @@ func defaultWebFetchConfig() WebFetchConfig { } } +func defaultContextConfig() ContextConfig { + return ContextConfig{ + Compact: defaultCompactConfig(), + } +} + +func defaultCompactConfig() CompactConfig { + return CompactConfig{ + MicroEnabled: true, + ToolResultKeepRecent: DefaultCompactToolResultKeepRecent, + ToolResultPlaceholderMinChars: DefaultCompactPlaceholderMinChars, + ManualStrategy: CompactManualStrategyKeepRecent, + ManualKeepRecentSpans: DefaultCompactManualKeepRecentSpans, + MaxSummaryChars: DefaultCompactMaxSummaryChars, + } +} + func (c ToolsConfig) Clone() ToolsConfig { return ToolsConfig{ WebFetch: c.WebFetch.Clone(), } } +func (c ContextConfig) Clone() ContextConfig { + return ContextConfig{ + Compact: c.Compact.Clone(), + } +} + func (c *ToolsConfig) ApplyDefaults(defaults ToolsConfig) { if c == nil { return @@ -319,6 +371,14 @@ func (c *ToolsConfig) ApplyDefaults(defaults ToolsConfig) { c.WebFetch.ApplyDefaults(defaults.WebFetch) } +func (c *ContextConfig) ApplyDefaults(defaults ContextConfig) { + if c == nil { + return + } + + c.Compact.ApplyDefaults(defaults.Compact) +} + func (c ToolsConfig) Validate() error { if err := c.WebFetch.Validate(); err != nil { return fmt.Errorf("webfetch: %w", err) @@ -326,12 +386,23 @@ func (c ToolsConfig) Validate() error { return nil } +func (c ContextConfig) Validate() error { + if err := c.Compact.Validate(); err != nil { + return fmt.Errorf("compact: %w", err) + } + return nil +} + func (c WebFetchConfig) Clone() WebFetchConfig { clone := c clone.SupportedContentTypes = append([]string(nil), c.SupportedContentTypes...) return clone } +func (c CompactConfig) Clone() CompactConfig { + return c +} + func (c *WebFetchConfig) ApplyDefaults(defaults WebFetchConfig) { if c == nil { return @@ -343,6 +414,28 @@ func (c *WebFetchConfig) ApplyDefaults(defaults WebFetchConfig) { c.SupportedContentTypes = normalizeContentTypes(c.SupportedContentTypes, defaults.SupportedContentTypes) } +func (c *CompactConfig) ApplyDefaults(defaults CompactConfig) { + if c == nil { + return + } + + if c.ToolResultKeepRecent <= 0 { + c.ToolResultKeepRecent = defaults.ToolResultKeepRecent + } + if c.ToolResultPlaceholderMinChars <= 0 { + c.ToolResultPlaceholderMinChars = defaults.ToolResultPlaceholderMinChars + } + if strings.TrimSpace(c.ManualStrategy) == "" { + c.ManualStrategy = defaults.ManualStrategy + } + if c.ManualKeepRecentSpans <= 0 { + c.ManualKeepRecentSpans = defaults.ManualKeepRecentSpans + } + if c.MaxSummaryChars <= 0 { + c.MaxSummaryChars = defaults.MaxSummaryChars + } +} + func (c WebFetchConfig) Validate() error { if c.MaxResponseBytes <= 0 { return errors.New("max_response_bytes must be greater than 0") @@ -359,6 +452,28 @@ func (c WebFetchConfig) Validate() error { return nil } +func (c CompactConfig) Validate() error { + if c.ToolResultKeepRecent <= 0 { + return errors.New("tool_result_keep_recent must be greater than 0") + } + if c.ToolResultPlaceholderMinChars <= 0 { + return errors.New("tool_result_placeholder_min_chars must be greater than 0") + } + if c.ManualKeepRecentSpans <= 0 { + return errors.New("manual_keep_recent_spans must be greater than 0") + } + if c.MaxSummaryChars <= 0 { + return errors.New("max_summary_chars must be greater than 0") + } + + switch strings.ToLower(strings.TrimSpace(c.ManualStrategy)) { + case CompactManualStrategyKeepRecent, CompactManualStrategyFullReplace: + return nil + default: + return fmt.Errorf("manual_strategy %q is not supported", c.ManualStrategy) + } +} + func normalizeContentTypes(values []string, defaults []string) []string { source := values if len(source) == 0 { diff --git a/internal/runtime/events.go b/internal/runtime/events.go index 0e2ce006..33ffa1bc 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -13,6 +13,13 @@ type RuntimeEvent struct { Payload any } +const ( + // CompactTriggerModeMicro labels compact payloads produced by the micro strategy. + CompactTriggerModeMicro = "micro" + // CompactTriggerModeManual labels compact payloads produced by manual compact requests. + CompactTriggerModeManual = "manual" +) + const ( // EventUserMessage is emitted after the user input has been accepted and saved. EventUserMessage EventType = "user_message" @@ -36,4 +43,12 @@ const ( // EventProviderRetry is emitted when runtime retries a provider call due to // a retryable error (e.g. 429, 5xx). Payload is a human-readable message. EventProviderRetry EventType = "provider_retry" + // EventCompactStart is emitted when a compact cycle starts. + EventCompactStart EventType = "compact_start" + // EventCompactDone is emitted when a compact cycle completes. + EventCompactDone EventType = "compact_done" + // EventCompactError is emitted when compact fails. + EventCompactError EventType = "compact_error" + // EventMicroCompactApplied is emitted when micro compact has rewritten context. + EventMicroCompactApplied EventType = "micro_compact_applied" ) From e6a68589d17735f4672cb0859e7831c141eb9eae Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Fri, 3 Apr 2026 11:14:58 +0800 Subject: [PATCH 3/5] =?UTF-8?q?chore(repo):=20=E5=BD=92=E6=A1=A3=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=88=86=E6=94=AF=E8=A1=A5=E4=B8=81=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 my-changes.patch 作为迁移与回溯参考\n- 与功能代码提交解耦,便于后续审阅与维护 --- my-changes.patch | Bin 0 -> 207870 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 my-changes.patch diff --git a/my-changes.patch b/my-changes.patch new file mode 100644 index 0000000000000000000000000000000000000000..4d82af6c344785f47936b64659ca2e1aaefb86e1 GIT binary patch literal 207870 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 Date: Fri, 3 Apr 2026 11:26:14 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(context):=20=E4=BF=AE=E5=A4=8D=20compac?= =?UTF-8?q?t=20=E5=AD=97=E7=AC=A6=E7=BB=9F=E8=AE=A1=E4=B8=8E=E8=A7=84?= =?UTF-8?q?=E5=88=99=E5=8F=91=E7=8E=B0=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compact 阈值与字符统计统一改为 rune 语义,避免中文按字节误判\n- discoverRuleFilesWithFinder 遇到目录读取错误时改为显式返回错误\n- 补充与更新对应测试用例,覆盖 rune 阈值/字符统计及错误传播行为 --- internal/context/compact/runner.go | 19 ++++---- internal/context/compact/runner_test.go | 63 +++++++++++++++++++++++++ internal/context/source_rules.go | 2 +- internal/context/source_rules_test.go | 10 ++-- 4 files changed, 79 insertions(+), 15 deletions(-) diff --git a/internal/context/compact/runner.go b/internal/context/compact/runner.go index 34050fbd..2d73a333 100644 --- a/internal/context/compact/runner.go +++ b/internal/context/compact/runner.go @@ -14,6 +14,7 @@ import ( goruntime "runtime" "strings" "time" + "unicode/utf8" "neo-code/internal/config" "neo-code/internal/provider" @@ -178,7 +179,7 @@ func hasMicroCandidate(messages []provider.Message, cfg config.CompactConfig) bo } candidateCount := len(toolIndices) - cfg.ToolResultKeepRecent for i := 0; i < candidateCount; i++ { - if len(messages[toolIndices[i]].Content) >= cfg.ToolResultPlaceholderMinChars { + if utf8.RuneCountInString(messages[toolIndices[i]].Content) >= cfg.ToolResultPlaceholderMinChars { return true } } @@ -206,7 +207,7 @@ func microCompact(messages []provider.Message, cfg config.CompactConfig) ([]prov for i := 0; i < candidateCount; i++ { idx := toolIndices[i] message := next[idx] - if len(message.Content) < cfg.ToolResultPlaceholderMinChars { + if utf8.RuneCountInString(message.Content) < cfg.ToolResultPlaceholderMinChars { continue } @@ -515,17 +516,17 @@ func cloneMessages(messages []provider.Message) []provider.Message { return out } -// countMessageChars 统计消息字符预算(当前按字符串字节长度计算)。 +// countMessageChars 统计消息字符预算(按 rune 字符数计算)。 func countMessageChars(messages []provider.Message) int { total := 0 for _, message := range messages { - total += len(message.Role) - total += len(message.Content) - total += len(message.ToolCallID) + total += utf8.RuneCountInString(message.Role) + total += utf8.RuneCountInString(message.Content) + total += utf8.RuneCountInString(message.ToolCallID) for _, call := range message.ToolCalls { - total += len(call.ID) - total += len(call.Name) - total += len(call.Arguments) + total += utf8.RuneCountInString(call.ID) + total += utf8.RuneCountInString(call.Name) + total += utf8.RuneCountInString(call.Arguments) } } return total diff --git a/internal/context/compact/runner_test.go b/internal/context/compact/runner_test.go index 966612fc..16ce3695 100644 --- a/internal/context/compact/runner_test.go +++ b/internal/context/compact/runner_test.go @@ -504,3 +504,66 @@ func TestValidateSummaryTruncatesByRune(t *testing.T) { t.Fatalf("expected summary to be truncated, got %q", got) } } + +func TestMicroCompactThresholdUsesRunes(t *testing.T) { + t.Parallel() + + runner := NewRunner() + home := t.TempDir() + runner.userHomeDir = func() (string, error) { return home, nil } + + // "你" 是单个 rune,但占用多个字节。 + longChinese := strings.Repeat("你", 12) + messages := []provider.Message{ + {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"}}}, + {Role: provider.RoleTool, ToolCallID: "call-1", Content: longChinese}, + {Role: provider.RoleTool, ToolCallID: "recent", Content: "keep recent"}, + } + + result, err := runner.Run(context.Background(), Input{ + Mode: ModeMicro, + SessionID: "session-rune-threshold", + Workdir: t.TempDir(), + Messages: messages, + Config: config.CompactConfig{ + MicroEnabled: true, + ToolResultKeepRecent: 1, + ToolResultPlaceholderMinChars: 12, + ManualStrategy: config.CompactManualStrategyKeepRecent, + ManualKeepRecentSpans: 6, + MaxSummaryChars: 1200, + }, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if !result.Applied { + t.Fatalf("expected chinese content to match rune threshold and be compacted") + } +} + +func TestCountMessageCharsUsesRunes(t *testing.T) { + t.Parallel() + + messages := []provider.Message{ + { + Role: "用户", + Content: "你好", + ToolCallID: "工具", + ToolCalls: []provider.ToolCall{ + {ID: "调用", Name: "读取", Arguments: `{"路径":"文件"}`}, + }, + }, + } + + got := countMessageChars(messages) + want := utf8.RuneCountInString("用户") + + utf8.RuneCountInString("你好") + + utf8.RuneCountInString("工具") + + utf8.RuneCountInString("调用") + + utf8.RuneCountInString("读取") + + utf8.RuneCountInString(`{"路径":"文件"}`) + if got != want { + t.Fatalf("countMessageChars() = %d, want %d", got, want) + } +} diff --git a/internal/context/source_rules.go b/internal/context/source_rules.go index 8dfc8d02..bcf3f419 100644 --- a/internal/context/source_rules.go +++ b/internal/context/source_rules.go @@ -82,7 +82,7 @@ func discoverRuleFilesWithFinder(ctx context.Context, workdir string, finder rul match, err := finder(dir) if err != nil { - break + return nil, fmt.Errorf("context: discover rule file in %s: %w", dir, err) } if match != "" { paths = append(paths, match) diff --git a/internal/context/source_rules_test.go b/internal/context/source_rules_test.go index fa32a1e7..c7491a1c 100644 --- a/internal/context/source_rules_test.go +++ b/internal/context/source_rules_test.go @@ -94,7 +94,7 @@ func TestLoadRuleDocumentsReturnsReadError(t *testing.T) { } } -func TestDiscoverRuleFilesStopsOnDirectoryReadError(t *testing.T) { +func TestDiscoverRuleFilesReturnsDirectoryReadError(t *testing.T) { t.Parallel() root := t.TempDir() @@ -125,11 +125,11 @@ func TestDiscoverRuleFilesStopsOnDirectoryReadError(t *testing.T) { return "", nil } }) - if err != nil { - t.Fatalf("discoverRuleFilesWithFinder() error = %v", err) + if err == nil || !strings.Contains(err.Error(), permissionErr.Error()) { + t.Fatalf("expected discoverRuleFilesWithFinder() to return permission error, got %v", err) } - if len(paths) != 1 || paths[0] != localRules { - t.Fatalf("expected traversal to stop after read error and keep collected paths, got %+v", paths) + if paths != nil { + t.Fatalf("expected no paths on discovery failure, got %+v", paths) } } From 9ae2efd0161d338e43f8a41feb9015fbe569f679 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Fri, 3 Apr 2026 11:46:50 +0800 Subject: [PATCH 5/5] =?UTF-8?q?test(runtime):=20=E8=A1=A5=E5=85=A8=20compa?= =?UTF-8?q?ct=20=E7=BB=84=E5=90=88=E5=9C=BA=E6=99=AF=E5=B9=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 manual 后继续 tool 回合与 manual+micro 组合集成测试;同步 README、配置指南与事件流文档中的 compact 说明。 --- README.md | 7 + docs/guides/configuration.md | 29 +++ docs/runtime-provider-event-flow.md | 41 ++++- internal/runtime/runtime_test.go | 264 ++++++++++++++++++++++++++++ 4 files changed, 335 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 317e8c20..4c515b5b 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,13 @@ go run ./cmd/neocode - `/provider` — 切换内建模型提供商 - `/model` — 切换模型 +- `/compact` — 手动压缩当前会话上下文 + +### 上下文压缩(Compact) + +- 每次向模型发起请求前,runtime 会先执行一次 `micro compact`,对旧且较长的 `tool result` 做轻量占位替换,控制上下文膨胀。 +- 你可以随时输入 `/compact` 触发 `manual compact`,把较早历史折叠为结构化 summary,并保留最近若干对话跨度。 +- 每次 compact 前都会先写入完整 transcript(`~/.neocode/projects//.transcripts/`),便于追溯和恢复历史。 ## 架构概览 diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index b668c8be..221d272d 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -59,6 +59,14 @@ current_model: gpt-4.1 workdir: /Users/username/projects/myproject shell: bash max_loops: 8 +context: + compact: + micro_enabled: true + tool_result_keep_recent: 3 + tool_result_placeholder_min_chars: 100 + manual_strategy: keep_recent + manual_keep_recent_spans: 6 + max_summary_chars: 1200 tools: webfetch: @@ -88,6 +96,17 @@ tools: | `tools.webfetch.max_response_bytes` | int | `1048576` (1MB) | WebFetch 工具最大响应字节数 | | `tools.webfetch.supported_content_types` | []string | `[text/html, text/plain, application/json]` | 支持的内容类型 | +#### 上下文压缩配置(Compact) + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `context.compact.micro_enabled` | bool | `true` | 是否在每轮 provider 请求前启用 micro compact | +| `context.compact.tool_result_keep_recent` | int | `3` | micro compact 中保留最近 N 条 tool result 原文 | +| `context.compact.tool_result_placeholder_min_chars` | int | `100` | micro compact 触发占位替换的最小字符数阈值 | +| `context.compact.manual_strategy` | string | `keep_recent` | 手动 `/compact` 策略,可选 `keep_recent` / `full_replace` | +| `context.compact.manual_keep_recent_spans` | int | `6` | `keep_recent` 模式下保留最近 N 个 span | +| `context.compact.max_summary_chars` | int | `1200` | 手动 compact 生成 summary 的最大字符数 | + ### 配置文件特点 **自动管理**: @@ -182,6 +201,16 @@ NeoCode 提供以下 slash 命令用于快速切换配置: gpt-5.3-codex ``` +### /compact - 手动压缩当前会话 + +``` +/compact +``` + +- 显式触发一次 manual compact。 +- 执行顺序为:写入 transcript -> 生成/校验 summary -> 重写会话消息 -> 发送 compact 事件。 +- 若压缩前 transcript 写盘失败,compact 会直接报错并终止,不会写坏会话。 + ## 配置管理 配置管理由 `internal/config` 模块负责: diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index e9e007e2..766458db 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -4,10 +4,19 @@ 当前 runtime 对外暴露一组小而稳定的事件: +- `user_message` - `agent_chunk` - `agent_done` - `tool_start` +- `tool_chunk` - `tool_result` +- `tool_call_thinking` +- `provider_retry` +- `compact_start` +- `compact_done` +- `compact_error` +- `micro_compact_applied` +- `run_canceled` - `error` ## ReAct 主循环 @@ -15,12 +24,31 @@ 1. 加载目标会话或创建新会话。 2. 追加最新的用户消息。 3. 读取最新配置快照。 -4. 解析当前 provider 配置并构建 provider 实例。 -5. 调用 `context.Builder` 生成本轮请求使用的 `system prompt` 和消息上下文。 -6. 调用 `Provider.Chat`,并把流式事件桥接给 TUI。 -7. 保存 assistant 完整回复。 -8. 执行返回的工具调用,并保存每一个工具结果。 -9. 如果仍需继续推理,则进入下一轮;否则结束。 +4. 每轮请求前执行一次 `micro compact`(轻量压缩旧 tool result)。 +5. 解析当前 provider 配置并构建 provider 实例。 +6. 调用 `context.Builder` 生成本轮请求使用的 `system prompt` 和消息上下文。 +7. 调用 `Provider.Chat`,并把流式事件桥接给 TUI。 +8. 保存 assistant 完整回复。 +9. 执行返回的工具调用,并保存每一个工具结果。 +10. 如果仍需继续推理,则进入下一轮;否则结束。 + +## 手动 `/compact` 命令链路 + +1. TUI 识别 `/compact` 并调用 `runtime.Compact(...)`。 +2. runtime 发出 `compact_start` 事件。 +3. compact 服务先写 transcript(完整原始消息,JSONL)。 +4. 按策略(`keep_recent` / `full_replace`)执行手动压缩并生成指标。 +5. runtime 回写 session(仅在 `applied=true` 时写回)。 +6. 成功时发出 `compact_done`;失败时发出 `compact_error`。 + +## Compact 结果与可观测字段 + +- `before_chars` +- `after_chars` +- `saved_ratio` +- `trigger_mode`(`micro` / `manual`) +- `transcript_id` +- `transcript_path` ### Context Builder 输入与职责 @@ -64,4 +92,5 @@ - 用户消息提交后保存 - assistant 完整回复后保存 - 每个工具结果完成后保存 +- 每次 compact 执行前先保存 transcript(完整留痕) - 避免在高频 UI 刷新路径中做磁盘 I/O diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 04f7bb28..2637a2ff 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -1462,6 +1462,270 @@ func TestServiceCompactManualFailureReturnsError(t *testing.T) { assertNoEventType(t, events, EventCompactDone) } +func TestServiceManualCompactThenRunContinuesToolRound(t *testing.T) { + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := newSession("manual-continue") + session.ID = "session-manual-continue" + session.Messages = []provider.Message{ + {Role: provider.RoleUser, Content: "legacy request"}, + {Role: provider.RoleAssistant, Content: "legacy answer"}, + } + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + tool := &stubTool{name: "filesystem_read_file", content: "file content"} + registry.Register(tool) + + scripted := &scriptedProvider{ + responses: []provider.ChatResponse{ + { + Message: provider.Message{ + Role: provider.RoleAssistant, + ToolCalls: []provider.ToolCall{ + { + ID: "call-continue-1", + Name: "filesystem_read_file", + Arguments: `{"path":"README.md"}`, + }, + }, + }, + FinishReason: "tool_calls", + }, + { + Message: provider.Message{ + Role: provider.RoleAssistant, + Content: "tool round still works", + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) + service.compactRunner = &stubCompactRunner{ + runFn: func(ctx context.Context, input contextcompact.Input) (contextcompact.Result, error) { + switch input.Mode { + case contextcompact.ModeManual: + compacted := []provider.Message{ + {Role: provider.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, + {Role: provider.RoleAssistant, Content: "latest kept"}, + } + return contextcompact.Result{ + Messages: compacted, + Applied: true, + Metrics: contextcompact.Metrics{ + BeforeChars: 120, + AfterChars: 40, + SavedRatio: 2.0 / 3.0, + TriggerMode: string(contextcompact.ModeManual), + }, + TranscriptID: "transcript_manual_then_run", + TranscriptPath: "/tmp/manual-then-run.jsonl", + }, nil + case contextcompact.ModeMicro: + before := len(input.Messages) + return contextcompact.Result{ + Messages: input.Messages, + Applied: false, + Metrics: contextcompact.Metrics{ + BeforeChars: before, + AfterChars: before, + SavedRatio: 0, + TriggerMode: string(contextcompact.ModeMicro), + }, + TranscriptID: "transcript_micro_check", + TranscriptPath: "/tmp/micro-check.jsonl", + }, nil + default: + return contextcompact.Result{}, fmt.Errorf("unexpected compact mode %q", input.Mode) + } + }, + } + + if _, err := service.Compact(context.Background(), CompactInput{ + SessionID: session.ID, + RunID: "run-manual-first", + }); err != nil { + t.Fatalf("Compact() error = %v", err) + } + + if err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-after-manual", + Content: "continue please", + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if tool.callCount != 1 { + t.Fatalf("expected tool to run once after manual compact, got %d", tool.callCount) + } + + saved, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load session: %v", err) + } + if len(saved.Messages) < 6 { + t.Fatalf("expected compacted history + new tool round, got %+v", saved.Messages) + } + if !strings.Contains(saved.Messages[0].Content, "compact_summary") { + t.Fatalf("expected compact summary to be kept in session, got %+v", saved.Messages) + } + + tail := saved.Messages[len(saved.Messages)-4:] + gotRoles := []string{tail[0].Role, tail[1].Role, tail[2].Role, tail[3].Role} + wantRoles := []string{provider.RoleUser, provider.RoleAssistant, provider.RoleTool, provider.RoleAssistant} + for i := range wantRoles { + if gotRoles[i] != wantRoles[i] { + t.Fatalf("unexpected tail roles: got %v, want %v", gotRoles, wantRoles) + } + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{ + EventCompactStart, + EventCompactDone, + EventUserMessage, + EventToolStart, + EventToolResult, + EventAgentDone, + }) + + manualDone := false + for _, event := range events { + if event.Type != EventCompactDone { + continue + } + payload, ok := event.Payload.(CompactDonePayload) + if ok && payload.TriggerMode == CompactTriggerModeManual { + manualDone = true + break + } + } + if !manualDone { + t.Fatalf("expected compact_done(manual) event, got %+v", events) + } +} + +func TestServiceManualAndMicroCompactCombination(t *testing.T) { + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Context.Compact.MicroEnabled = true + return nil + }); err != nil { + t.Fatalf("enable micro compact: %v", err) + } + + store := newMemoryStore() + session := newSession("manual-micro") + session.ID = "session-manual-micro" + session.Messages = []provider.Message{ + {Role: provider.RoleUser, Content: "old context"}, + {Role: provider.RoleAssistant, Content: "old answer"}, + } + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "ok"}) + + builder := &stubContextBuilder{} + scripted := &scriptedProvider{ + responses: []provider.ChatResponse{ + { + Message: provider.Message{ + Role: provider.RoleAssistant, + Content: "done", + }, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) + service.compactRunner = &stubCompactRunner{ + runFn: func(ctx context.Context, input contextcompact.Input) (contextcompact.Result, error) { + switch input.Mode { + case contextcompact.ModeManual: + compacted := []provider.Message{ + {Role: provider.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, + {Role: provider.RoleAssistant, Content: "recent assistant"}, + } + return contextcompact.Result{ + Messages: compacted, + Applied: true, + Metrics: contextcompact.Metrics{ + BeforeChars: 80, + AfterChars: 35, + SavedRatio: 0.5625, + TriggerMode: string(contextcompact.ModeManual), + }, + TranscriptID: "transcript_combo_manual", + TranscriptPath: "/tmp/combo-manual.jsonl", + }, nil + case contextcompact.ModeMicro: + if len(input.Messages) == 0 || !strings.Contains(input.Messages[0].Content, "compact_summary") { + return contextcompact.Result{}, errors.New("micro compact did not receive manual compacted context") + } + next := append([]provider.Message(nil), input.Messages...) + next = append(next, provider.Message{Role: provider.RoleAssistant, Content: "micro-marker"}) + return contextcompact.Result{ + Messages: next, + Applied: true, + Metrics: contextcompact.Metrics{ + BeforeChars: 35, + AfterChars: 28, + SavedRatio: 0.2, + TriggerMode: string(contextcompact.ModeMicro), + }, + TranscriptID: "transcript_combo_micro", + TranscriptPath: "/tmp/combo-micro.jsonl", + }, nil + default: + return contextcompact.Result{}, fmt.Errorf("unexpected compact mode %q", input.Mode) + } + }, + } + + if _, err := service.Compact(context.Background(), CompactInput{ + SessionID: session.ID, + RunID: "run-combo-manual", + }); err != nil { + t.Fatalf("Compact() error = %v", err) + } + + if err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-combo-micro", + Content: "follow up", + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(builder.lastInput.Messages) == 0 { + t.Fatalf("expected context builder input") + } + if builder.lastInput.Messages[len(builder.lastInput.Messages)-1].Content != "micro-marker" { + t.Fatalf("expected micro compact output to be passed into builder, got %+v", builder.lastInput.Messages) + } + + calls := service.compactRunner.(*stubCompactRunner).calls + if len(calls) < 2 { + t.Fatalf("expected manual + micro compact calls, got %+v", calls) + } + if calls[0].Mode != contextcompact.ModeManual || calls[1].Mode != contextcompact.ModeMicro { + t.Fatalf("expected compact call order manual -> micro, got %+v", calls) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{ + EventCompactStart, + EventCompactDone, + EventMicroCompactApplied, + EventAgentDone, + }) +} + func TestServiceSerializesRunAndCompact(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore()