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/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/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/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..2d73a333 --- /dev/null +++ b/internal/context/compact/runner.go @@ -0,0 +1,543 @@ +package compact + +import ( + "context" + cryptorand "crypto/rand" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + goruntime "runtime" + "strings" + "time" + "unicode/utf8" + + "neo-code/internal/config" + "neo-code/internal/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 utf8.RuneCountInString(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 utf8.RuneCountInString(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 统计消息字符预算(按 rune 字符数计算)。 +func countMessageChars(messages []provider.Message) int { + total := 0 + for _, message := range messages { + total += utf8.RuneCountInString(message.Role) + total += utf8.RuneCountInString(message.Content) + total += utf8.RuneCountInString(message.ToolCallID) + for _, call := range message.ToolCalls { + total += utf8.RuneCountInString(call.ID) + total += utf8.RuneCountInString(call.Name) + total += utf8.RuneCountInString(call.Arguments) + } + } + return total +} + +// 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..16ce3695 --- /dev/null +++ b/internal/context/compact/runner_test.go @@ -0,0 +1,569 @@ +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) + } +} + +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/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..bcf3f419 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 == "" { @@ -78,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) @@ -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_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) } } 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/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" ) 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..2637a2ff 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,563 @@ 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 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() + 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 +1836,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 +1884,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 +1998,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) diff --git a/my-changes.patch b/my-changes.patch new file mode 100644 index 00000000..4d82af6c Binary files /dev/null and b/my-changes.patch differ