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..6c6d15ea 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,9 @@ go run ./cmd/neocode - `/provider` — 切换内建模型提供商 - `/model` — 切换模型 +- `/compact` — 手动压缩当前会话上下文(保留可追溯 transcript) + +上下文压缩默认开启每轮 `micro_compact`,手动 `/compact` 支持 `keep_recent` 与 `full_replace` 两种策略。 ## 架构概览 diff --git a/docs/config-management-detail-design.md b/docs/config-management-detail-design.md index 8975eb90..9e79bb52 100644 --- a/docs/config-management-detail-design.md +++ b/docs/config-management-detail-design.md @@ -8,6 +8,17 @@ ## 核心类型 - `Config`:顶层应用配置,运行时包含 Provider 列表、当前选中 Provider、当前模型、工作目录、Shell 和循环限制等信息 +- `Config.Context.Compact`:上下文压缩配置,包含: + - `micro_enabled` + - `tool_result_keep_recent` + - `tool_result_placeholder_min_chars` + - `manual_strategy`(M1 支持 `keep_recent` / `full_replace`) + - `manual_keep_recent_spans` + - `max_summary_chars` + +默认策略说明: +- `micro_enabled` 默认 `true`,可通过配置显式关闭 +- `manual_strategy` 默认 `keep_recent` - `ProviderConfig`:单个 Provider 的内建定义,包括 Base URL、默认模型、实例级模型列表和 API Key 环境变量名 - `Manager`:使用 `sync.RWMutex` 保护的配置访问器与修改器 - `Loader`:对 YAML 文件和托管 `.env` 文件的文件系统封装 diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index e9e007e2..83a15e37 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -9,18 +9,23 @@ - `tool_start` - `tool_result` - `error` +- `compact_start` +- `compact_done` +- `compact_error` +- `micro_compact_applied` ## ReAct 主循环 1. 加载目标会话或创建新会话。 2. 追加最新的用户消息。 3. 读取最新配置快照。 -4. 解析当前 provider 配置并构建 provider 实例。 -5. 调用 `context.Builder` 生成本轮请求使用的 `system prompt` 和消息上下文。 -6. 调用 `Provider.Chat`,并把流式事件桥接给 TUI。 -7. 保存 assistant 完整回复。 -8. 执行返回的工具调用,并保存每一个工具结果。 -9. 如果仍需继续推理,则进入下一轮;否则结束。 +4. 每轮请求前先尝试执行 `micro_compact`(失败时降级跳过,不阻断主链路)。 +5. 解析当前 provider 配置并构建 provider 实例。 +6. 调用 `context.Builder` 生成本轮请求使用的 `system prompt` 和消息上下文。 +7. 调用 `Provider.Chat`,并把流式事件桥接给 TUI。 +8. 保存 assistant 完整回复。 +9. 执行返回的工具调用,并保存每一个工具结果。 +10. 如果仍需继续推理,则进入下一轮;否则结束。 ### Context Builder 输入与职责 @@ -59,6 +64,20 @@ - runtime 将其转换成 `RuntimeEvent` - TUI 使用 Bubble Tea `Cmd` 监听事件,并在处理完成后继续订阅 +### Compact 事件 + +- `compact_start`:开始执行 compact(payload 为触发模式,`micro` 或 `manual`) +- `compact_done`:compact 完成,payload 包含: + - `applied` + - `before_chars` + - `after_chars` + - `saved_ratio` + - `trigger_mode` + - `transcript_id` + - `transcript_path` +- `compact_error`:compact 失败,payload 包含触发模式和错误信息 +- `micro_compact_applied`:仅当 `micro_compact` 实际改写了消息时触发 + ## 持久化时机 - 用户消息提交后保存 diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md index 0c5d1887..4f5d1cde 100644 --- a/docs/session-persistence-design.md +++ b/docs/session-persistence-design.md @@ -2,14 +2,18 @@ ## 存储策略 NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优先、易于调试和跨平台可移植。 +除会话主存储外,compact 会在压缩前额外写入 transcript 留痕,确保“移出活跃上下文”而非“不可恢复删除”。 + ## 数据模型 - `Session`:完整消息历史以及 `id`、`title`、`updated_at` 等元信息 - `SessionSummary`:用于侧边栏的轻量摘要结构 +- `Transcript(.jsonl)`:compact 前完整消息快照,每行一条消息,包含 `role/content/tool_calls/tool_call_id/is_error/index/timestamp` ## 加载策略 - `ListSummaries` 只读取渲染侧边栏所需的基础信息 - `Load` 仅在用户真正进入某个会话时读取完整消息历史 - `Save` 通过临时文件原子写入完整 Session +- transcript 当前不参与自动加载回放,仅用于追溯与审计 ## 命名策略 - 新会话默认展示为 `Draft` @@ -18,3 +22,5 @@ NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优 ## 并发约束 - SessionStore 实现必须自行保护共享访问 - 真正的保存时机由 runtime 决定,TUI 不负责直接触发磁盘写入 +- compact transcript 也通过原子落盘写入,路径为 `~/.neocode/projects//.transcripts/` +- transcript 文件在 Unix 系统默认使用 `0600` 权限,减少敏感内容泄露面 diff --git a/docs/tools-and-tui-integration.md b/docs/tools-and-tui-integration.md index b41a38a3..c092536e 100644 --- a/docs/tools-and-tui-integration.md +++ b/docs/tools-and-tui-integration.md @@ -22,6 +22,8 @@ ## TUI 集成方式 - 本地配置操作统一通过 Slash Command 完成,例如 Base URL、API Key 和模型选择 +- `/compact` 提供手动上下文压缩入口,触发 runtime 的 manual compact 流程 +- TUI 对 compact 仅做轻量可观测展示:显示 `compact_done` / `compact_error` 关键信息,避免事件刷屏 - runtime 事件以内联形式渲染到 transcript 中,而不是单独拆出控制台面板 - 工具开始和结束事件会以轻量提示插入聊天流,使交互更沉浸 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..0aff326c 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -3,11 +3,14 @@ package context import "context" // DefaultBuilder preserves the current runtime context-building behavior. +// DefaultBuilder 是 Context 构建器的默认实现,负责把运行时状态整理成模型可消费的输入。 type DefaultBuilder struct { + // gitRunner 用于采集 git 状态,保留为可注入函数,便于测试替换。 gitRunner gitCommandRunner } // NewBuilder returns the default context builder implementation. +// NewBuilder 返回默认构建器;当前项目的上下文构建统一从这里进入。 func NewBuilder() Builder { return &DefaultBuilder{ gitRunner: runGitCommand, @@ -15,26 +18,35 @@ func NewBuilder() Builder { } // Build assembles the provider-facing context for the current round. +// Build 按固定顺序构建本轮上下文: +// 1) 检查上下文取消状态; +// 2) 读取项目规则(如 AGENTS.md); +// 3) 收集系统态(workdir/shell/provider/model/git); +// 4) 拼装系统提示词并裁剪历史消息。 func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResult, error) { if err := ctx.Err(); err != nil { return BuildResult{}, err } + // 项目规则会按目录层级汇总并受长度预算限制,避免 system prompt 失控增长。 rules, err := loadProjectRules(ctx, input.Metadata.Workdir) if err != nil { return BuildResult{}, err } + // 系统态为模型提供当前执行环境信息,帮助其做出更准确决策。 systemState, err := collectSystemState(ctx, input.Metadata, b.gitRunner) if err != nil { return BuildResult{}, err } + // 先放默认约束,再追加项目规则和系统态,确保提示结构稳定可预测。 sections := append([]promptSection{}, defaultSystemPromptSections()...) sections = append(sections, renderProjectRulesSection(rules)) sections = append(sections, renderSystemStateSection(systemState)) return BuildResult{ + // Messages 会进行窗口裁剪,避免超长历史影响模型性能与成本。 SystemPrompt: composeSystemPrompt(sections...), Messages: trimMessages(input.Messages), }, nil diff --git a/internal/context/compact/runner.go b/internal/context/compact/runner.go new file mode 100644 index 00000000..3d6e82f0 --- /dev/null +++ b/internal/context/compact/runner.go @@ -0,0 +1,540 @@ +package compact + +import ( + "context" + cryptorand "crypto/rand" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + goruntime "runtime" + "strings" + "time" + + "neo-code/internal/config" + "neo-code/internal/provider" +) + +// Mode 定义 compact 执行模式。 +type Mode string + +const ( + // ModeMicro:轻量压缩,仅替换较早的大体积 tool 结果内容为占位文本。 + ModeMicro Mode = "micro" + // ModeManual:手动压缩,按策略进行摘要替换或保留最近窗口。 + ModeManual Mode = "manual" +) + +// ErrorMode 描述压缩流程中的降级模式(当前仅保留占位定义)。 +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 记录压缩前后体量变化与触发方式。 +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 提供可注入依赖,便于测试控制时间、随机数与文件系统副作用。 +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) 先落盘完整转录,再对消息做压缩; +// 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 compact is always evaluated when explicitly requested. + default: + return Result{}, fmt.Errorf("compact: unsupported mode %q", input.Mode) + } + + // 无论后续是否实际应用压缩,都先保存转录,保证可追溯。 + transcriptID, transcriptPath, err := s.saveTranscript(messages, strings.TrimSpace(input.SessionID), strings.TrimSpace(input.Workdir)) + if err != nil { + return Result{}, err + } + base.TranscriptID = transcriptID + base.TranscriptPath = transcriptPath + + var ( + next []provider.Message + applied bool + ) + + // 根据模式分派具体压缩策略。 + switch input.Mode { + case ModeMicro: + next, applied = microCompact(messages, cfg) + case ModeManual: + next, applied, err = manualCompact(messages, cfg) + if err != nil { + return Result{}, err + } + } + + afterChars := countMessageChars(next) + result := base + result.Messages = next + result.Applied = applied + result.Metrics.AfterChars = afterChars + if beforeChars > 0 { + result.Metrics.SavedRatio = float64(beforeChars-afterChars) / float64(beforeChars) + } + return result, nil +} + +// hasMicroCandidate 判断是否存在可被 micro 替换的早期 tool 结果。 +// 最近 N 条 tool 结果永远保留原文,避免影响当前推理连续性。 +func hasMicroCandidate(messages []provider.Message, cfg config.CompactConfig) bool { + toolIndices := make([]int, 0, len(messages)) + for i, message := range messages { + if message.Role == provider.RoleTool { + toolIndices = append(toolIndices, i) + } + } + if len(toolIndices) <= cfg.ToolResultKeepRecent { + return false + } + candidateCount := len(toolIndices) - cfg.ToolResultKeepRecent + for i := 0; i < candidateCount; i++ { + if len(messages[toolIndices[i]].Content) >= cfg.ToolResultPlaceholderMinChars { + return true + } + } + return false +} + +// microCompact 将较早且足够长的 tool 输出替换为占位文本。 +func microCompact(messages []provider.Message, cfg config.CompactConfig) ([]provider.Message, bool) { + next := cloneMessages(messages) + + toolIndices := make([]int, 0, len(next)) + for i, message := range next { + if message.Role == provider.RoleTool { + toolIndices = append(toolIndices, i) + } + } + + if len(toolIndices) <= cfg.ToolResultKeepRecent { + return next, false + } + + toolNameByCallID := buildToolNameIndex(next) + candidateCount := len(toolIndices) - cfg.ToolResultKeepRecent + applied := false + for i := 0; i < candidateCount; i++ { + idx := toolIndices[i] + message := next[idx] + if len(message.Content) < cfg.ToolResultPlaceholderMinChars { + continue + } + + toolName := strings.TrimSpace(toolNameByCallID[message.ToolCallID]) + if toolName == "" { + toolName = "unknown_tool" + } + // 占位文本保留“调用过哪个工具”的关键信息,压缩体量同时保留语义线索。 + placeholder := fmt.Sprintf("[Previous tool used: %s]", toolName) + if message.Content == placeholder { + continue + } + message.Content = placeholder + next[idx] = message + applied = true + } + + return next, applied +} + +// buildToolNameIndex 将 tool_call_id 映射到工具名,供 micro 占位文本使用。 +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 保留最近若干片段,旧片段汇总为一条 assistant 摘要消息。 +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 将全部历史替换为一条摘要消息。 +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 生成结构化摘要,至少包含 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 做摘要最小合法性校验,并施加最大长度约束。 +func validateSummary(summary string, maxChars int) (string, error) { + summary = strings.TrimSpace(summary) + if maxChars > 0 && len(summary) > maxChars { + summary = strings.TrimSpace(summary[: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 使用正则判断某个段落是否存在项目符号内容。 +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 是写入 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 以原子写入方式保存压缩前消息转录: +// 先写 .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 上使用更严格权限保护转录内容。 +func transcriptFileMode() os.FileMode { + if goruntime.GOOS == "windows" { + return 0o644 + } + return 0o600 +} + +// randomTranscriptToken 生成短随机 token,用于转录文件名去重。 +func randomTranscriptToken() (string, error) { + entropy := make([]byte, 4) + if _, err := cryptorand.Read(entropy); err != nil { + return "", err + } + return hex.EncodeToString(entropy), nil +} + +// hashProject 基于工作目录计算稳定哈希,用于项目级转录目录隔离。 +func hashProject(workdir string) string { + clean := strings.TrimSpace(filepath.Clean(workdir)) + if clean == "" { + clean = "unknown" + } + sum := sha1.Sum([]byte(strings.ToLower(clean))) + return hex.EncodeToString(sum[:8]) +} + +var nonIDChars = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) + +// sanitizeID 清理 session id,确保可安全用于文件名。 +func sanitizeID(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + return nonIDChars.ReplaceAllString(value, "_") +} + +// cloneMessages 深拷贝消息切片,避免压缩过程污染调用方原始数据。 +func cloneMessages(messages []provider.Message) []provider.Message { + if len(messages) == 0 { + return nil + } + out := make([]provider.Message, 0, len(messages)) + for _, message := range messages { + next := message + next.ToolCalls = append([]provider.ToolCall(nil), message.ToolCalls...) + out = append(out, next) + } + return out +} + +// countMessageChars 估算消息体量,作为压缩收益指标。 +func countMessageChars(messages []provider.Message) int { + total := 0 + for _, message := range messages { + total += len(message.Role) + total += len(message.Content) + total += len(message.ToolCallID) + for _, call := range message.ToolCalls { + total += len(call.ID) + total += len(call.Name) + total += len(call.Arguments) + } + } + return total +} + +// normalizeCompactConfig 将 compact 配置补齐默认值并规范关键字段。 +func normalizeCompactConfig(cfg config.CompactConfig) config.CompactConfig { + defaults := config.Default().Context.Compact + cfg.ApplyDefaults(defaults) + if strings.TrimSpace(cfg.ManualStrategy) == "" { + cfg.ManualStrategy = config.CompactManualStrategyKeepRecent + } + return cfg +} diff --git a/internal/context/compact/runner_test.go b/internal/context/compact/runner_test.go new file mode 100644 index 00000000..5f1a5b7e --- /dev/null +++ b/internal/context/compact/runner_test.go @@ -0,0 +1,477 @@ +package compact + +import ( + "context" + "errors" + "os" + "path/filepath" + goruntime "runtime" + "strings" + "testing" + "time" + + "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) + } +} diff --git a/internal/context/metadata.go b/internal/context/metadata.go index bec1f559..4bfc56c2 100644 --- a/internal/context/metadata.go +++ b/internal/context/metadata.go @@ -1,21 +1,31 @@ package context // Metadata contains the non-message runtime state needed by context sources. +// Metadata 描述消息之外的运行时元信息,由 runtime 传入 context 模块。 type Metadata struct { - Workdir string - Shell string + // Workdir 是当前工作目录,通常用于规则发现和 git 状态采集。 + Workdir string + // Shell 是当前会话使用的 shell 名称(如 bash/powershell)。 + Shell string + // Provider 是当前选中的模型服务提供方标识。 Provider string - Model string + // Model 是当前实际使用的模型名称。 + Model string } // GitState is the summarized git metadata exposed to the prompt builder. +// GitState 是 git 状态的最小摘要,避免把冗余仓库信息注入提示词。 type GitState struct { + // Available 表示当前目录可成功读取 git 元数据。 Available bool - Branch string - Dirty bool + // Branch 是当前分支名。 + Branch string + // Dirty 表示工作区是否存在未提交改动。 + Dirty bool } // SystemState is the summarized runtime metadata exposed to the prompt builder. +// SystemState 是供提示词渲染使用的系统态快照。 type SystemState struct { Workdir string Shell string diff --git a/internal/context/prompt.go b/internal/context/prompt.go index 22cc66b7..e506f804 100644 --- a/internal/context/prompt.go +++ b/internal/context/prompt.go @@ -2,11 +2,15 @@ package context import "strings" +// promptSection 表示 system prompt 的一个结构化分节。 +// 约定:title 为空时只输出内容;content 为空时该节忽略。 type promptSection struct { title string content string } +// defaultPromptSections 定义默认系统提示词骨架。 +// 这些内容为通用行为约束,项目规则与系统态会在后续阶段追加。 var defaultPromptSections = []promptSection{ { title: "Agent Identity", @@ -49,6 +53,8 @@ func defaultSystemPromptSections() []promptSection { return defaultPromptSections } +// composeSystemPrompt 将多个分节拼接为最终系统提示词。 +// 空分节会被自动跳过,保证输出紧凑。 func composeSystemPrompt(sections ...promptSection) string { rendered := make([]string, 0, len(sections)) for _, section := range sections { @@ -61,6 +67,10 @@ func composeSystemPrompt(sections ...promptSection) string { return strings.Join(rendered, "\n\n") } +// renderPromptSection 负责单节渲染,输出格式为: +// ## 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..6cd91e0b 100644 --- a/internal/context/source_rules.go +++ b/internal/context/source_rules.go @@ -15,14 +15,17 @@ const ( maxTotalRuleRunes = 12000 ) +// ruleDocument 表示单个规则文件在提示词中的投影。 type ruleDocument struct { Path string Content string Truncated bool } +// ruleFileFinder 抽象目录扫描逻辑,便于在测试中注入假实现。 type ruleFileFinder func(string) (string, error) +// loadProjectRules 从 workdir 向上逐级发现并加载规则文件。 func loadProjectRules(ctx context.Context, workdir string) ([]ruleDocument, error) { paths, err := discoverRuleFiles(ctx, workdir) if err != nil { @@ -32,6 +35,7 @@ func loadProjectRules(ctx context.Context, workdir string) ([]ruleDocument, erro return loadRuleDocuments(ctx, paths, os.ReadFile) } +// loadRuleDocuments 读取并截断规则文件内容,避免单文件过大挤占上下文预算。 func loadRuleDocuments(ctx context.Context, paths []string, readFile func(string) ([]byte, error)) ([]ruleDocument, error) { documents := make([]ruleDocument, 0, len(paths)) for _, path := range paths { @@ -55,10 +59,13 @@ func loadRuleDocuments(ctx context.Context, paths []string, readFile func(string return documents, nil } +// discoverRuleFiles 执行默认规则文件发现策略。 func discoverRuleFiles(ctx context.Context, workdir string) ([]string, error) { return discoverRuleFilesWithFinder(ctx, workdir, findExactRuleFile) } +// discoverRuleFilesWithFinder 从当前目录向上遍历到根目录,收集匹配规则文件。 +// 返回顺序会反转为“从上层到下层”,让更顶层规则先出现。 func discoverRuleFilesWithFinder(ctx context.Context, workdir string, finder ruleFileFinder) ([]string, error) { workdir = strings.TrimSpace(workdir) if workdir == "" { @@ -98,6 +105,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 +127,8 @@ func findExactRuleFile(dir string) (string, error) { return "", nil } +// renderProjectRulesSection 在总预算内渲染规则分节。 +// 当预算不足时,优先保留靠近工作目录的规则并标记截断提示。 func renderProjectRulesSection(documents []ruleDocument) promptSection { if len(documents) == 0 { return promptSection{} @@ -167,6 +177,7 @@ func renderProjectRulesSection(documents []ruleDocument) promptSection { } } +// renderRuleDocumentChunk 渲染单个规则文件块(不考虑总预算)。 func renderRuleDocumentChunk(document ruleDocument) string { var builder strings.Builder builder.WriteString("\n### ") @@ -184,6 +195,8 @@ func renderRuleDocumentChunk(document ruleDocument) string { return builder.String() } +// renderRuleDocumentChunkWithinBudget 在给定字符预算内渲染单个规则文件块。 +// 若预算不足以容纳标题,则直接返回空字符串。 func renderRuleDocumentChunkWithinBudget(document ruleDocument, budget int) string { if budget <= 0 { return "" @@ -224,6 +237,7 @@ func renderRuleDocumentChunkWithinBudget(document ruleDocument, budget int) stri return header + body.String() } +// truncateRunes 按 rune(而非字节)截断,避免 UTF-8 多字节字符被破坏。 func truncateRunes(input string, max int) (string, bool) { if max <= 0 { return "", input != "" @@ -236,6 +250,7 @@ func truncateRunes(input string, max int) (string, bool) { return string(runes[:max]), true } +// runeCount 统一使用 rune 数进行长度预算计算。 func runeCount(input string) int { return utf8.RuneCountInString(input) } diff --git a/internal/context/source_system.go b/internal/context/source_system.go index 6ee12e0d..67699b6d 100644 --- a/internal/context/source_system.go +++ b/internal/context/source_system.go @@ -8,8 +8,11 @@ import ( "strings" ) +// gitCommandRunner 抽象 git 命令执行,方便测试替身注入。 type gitCommandRunner func(ctx context.Context, workdir string, args ...string) (string, error) +// collectSystemState 聚合运行时环境信息,并尽力探测 git 状态。 +// 非上下文取消类 git 错误会被吞掉,以保证主流程可继续。 func collectSystemState(ctx context.Context, metadata Metadata, runner gitCommandRunner) (SystemState, error) { state := SystemState{ Workdir: strings.TrimSpace(metadata.Workdir), @@ -25,6 +28,7 @@ func collectSystemState(ctx context.Context, metadata Metadata, runner gitComman return state, nil } + // 当前分支读取失败时,不中断上下文构建;只有上下文取消才返回错误。 branch, err := runner(ctx, state.Workdir, "rev-parse", "--abbrev-ref", "HEAD") if err != nil { if isContextError(err) { @@ -32,6 +36,7 @@ func collectSystemState(ctx context.Context, metadata Metadata, runner gitComman } return state, nil } + // 通过 porcelain 输出是否为空判断工作区是否脏。 dirty, err := runner(ctx, state.Workdir, "status", "--porcelain") if err != nil { if isContextError(err) { @@ -48,6 +53,7 @@ func collectSystemState(ctx context.Context, metadata Metadata, runner gitComman return state, nil } +// renderSystemStateSection 将系统态转为结构化提示词文本。 func renderSystemStateSection(state SystemState) promptSection { lines := []string{ fmt.Sprintf("- workdir: `%s`", promptValue(state.Workdir)), @@ -72,6 +78,7 @@ func renderSystemStateSection(state SystemState) promptSection { } } +// runGitCommand 使用 `git -C ` 在指定目录执行 git 子命令。 func runGitCommand(ctx context.Context, workdir string, args ...string) (string, error) { command := exec.CommandContext(ctx, "git", append([]string{"-C", workdir}, args...)...) output, err := command.Output() @@ -81,6 +88,7 @@ func runGitCommand(ctx context.Context, workdir string, args ...string) (string, return string(output), nil } +// promptValue 把空值规范为 unknown,避免向提示词暴露空字符串。 func promptValue(value string) string { value = strings.TrimSpace(value) if value == "" { @@ -89,6 +97,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..47fed25e 100644 --- a/internal/context/trim.go +++ b/internal/context/trim.go @@ -4,6 +4,9 @@ import "neo-code/internal/provider" const maxContextTurns = 10 +// trimMessages 将历史消息裁剪到最近 maxContextTurns 个“对话片段”。 +// 片段定义:普通单条消息算一个片段;assistant(tool_calls)+后续tool结果算一个整体片段。 +// 这样可以在压缩上下文时尽量保持 tool_call 与 tool_result 的配对完整性。 func trimMessages(messages []provider.Message) []provider.Message { if len(messages) <= maxContextTurns { return append([]provider.Message(nil), messages...) @@ -19,6 +22,7 @@ func trimMessages(messages []provider.Message) []provider.Message { start := i i++ + // assistant 触发工具后,连续的 tool 消息都归属到同一片段。 if messages[start].Role == provider.RoleAssistant && len(messages[start].ToolCalls) > 0 { for i < len(messages) && messages[i].Role == provider.RoleTool { i++ @@ -32,6 +36,7 @@ func trimMessages(messages []provider.Message) []provider.Message { return append([]provider.Message(nil), messages...) } + // 仅保留最近 N 个片段,从片段起点切割,避免破坏消息对齐关系。 start := spans[len(spans)-maxContextTurns].start return append([]provider.Message(nil), messages[start:]...) } diff --git a/internal/context/types.go b/internal/context/types.go index 6eb516dd..b7fbc42f 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -7,17 +7,20 @@ import ( ) // 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..4f78e3b7 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,12 @@ 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 // 事件通道,Runtime 在运行过程中产生的事件都通过该通道发送给 TUI 层消费和展示。 + runMu sync.Mutex // 运行互斥锁,保证同一时间只有一个 Run 在执行。 + activeRunToken uint64 // 当前活跃运行的令牌标识,用于标记正在执行的 Run 实例。 + nextRunToken uint64 // 下一个运行令牌的递增计数器,用于区分不同 Run 的生命周期。 + activeRunCancel context.CancelFunc // 当前活跃 Run 的取消函数。 } func NewWithFactory( @@ -82,6 +115,7 @@ func NewWithFactory( toolManager: toolManager, providerFactory: providerFactory, contextBuilder: contextBuilder, + compactRunner: contextcompact.NewRunner(), events: make(chan RuntimeEvent, 128), } } @@ -131,6 +165,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 +285,36 @@ 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") + } + + 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 +433,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, string(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: string(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 +585,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..2699740e 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,206 @@ 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 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 +1479,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 +1527,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 +1641,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..7d03360d 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" @@ -108,6 +111,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"}, diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index 903bdaa9..32c0747d 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, diff --git a/internal/tui/update.go b/internal/tui/update.go index 5d8d82a9..f6352183 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 @@ -103,6 +106,26 @@ 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. + 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() @@ -525,6 +548,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 @@ -769,7 +836,7 @@ func (a *App) rebuildTranscript() { } 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 +844,18 @@ 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 + } + a.state.StatusText = statusCompacting + a.state.ExecutionError = "" + return true, runCompact(a.runtime, a.state.ActiveSessionID) default: return false, nil } @@ -848,3 +927,10 @@ 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} + } +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 6942e988..1b0dd5ce 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,40 @@ 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) + } + 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 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")