From 7fcf0d4765b1c91abf82eb5d84387aac65d89e7a Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 2 Apr 2026 09:28:24 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E9=87=8D=E6=9E=84=20system=20prompt=20sect?= =?UTF-8?q?ion=20=E7=BB=84=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- docs/runtime-provider-event-flow.md | 5 +- internal/context/builder.go | 12 ++-- internal/context/builder_test.go | 37 ++++++++++- internal/context/prompt.go | 89 ++++++++++++++++++++++---- internal/context/source_rules.go | 11 ++-- internal/context/source_rules_test.go | 18 ++++-- internal/context/source_system.go | 10 +-- internal/context/source_system_test.go | 7 +- 9 files changed, 152 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 9a1f2b9c..bd65dfe8 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ go run ./cmd/neocode 核心模块职责: - **`internal/config`** — 配置管理、环境变量、YAML 加载 -- **`internal/context`** — system prompt、消息裁剪与上下文构建 +- **`internal/context`** — 多 section system prompt 组装、消息裁剪与上下文构建 - **`internal/provider`** — LLM 提供商抽象,抹平厂商差异 - **`internal/runtime`** — ReAct 主循环、事件流、会话管理 - **`internal/tools`** — 工具注册表与具体工具实现 @@ -107,7 +107,7 @@ go run ./cmd/neocode ├── internal │ ├── app # 应用装配 │ ├── config # 配置管理 -│ ├── context # 上下文构建 +│ ├── context # system prompt 组装与上下文构建 │ ├── provider # Provider 抽象与实现 │ ├── runtime # ReAct 循环与事件流 │ ├── tools # 工具系统 diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index c39c740a..a439bc59 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -31,7 +31,7 @@ - 当前 `provider` - 当前 `model` - `context.Builder` 负责统一组装: - - 固定核心 system prompt + - 固定核心 system prompt sections - 从 `workdir` 向上发现的 `AGENTS.md` - 系统状态摘要(`workdir` / `shell` / `provider` / `model` / git branch / git dirty) - 裁剪后的历史消息 @@ -42,7 +42,7 @@ 当前 `system prompt` 按以下顺序拼装: -1. 固定核心指令 +1. 固定核心 sections 2. `Project Rules` section 3. `System State` section @@ -51,6 +51,7 @@ - 规则文件只支持大写文件名 `AGENTS.md` - 多份命中结果按“从全局到局部”的顺序注入 - git 只注入摘要,不注入完整 `git status` +- 各 section 统一由 `internal/context` 内部 composer 渲染,`runtime` 仍只消费最终字符串 ## 流式桥接 diff --git a/internal/context/builder.go b/internal/context/builder.go index bc57052b..abd9523a 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -30,12 +30,12 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu return BuildResult{}, err } + sections := append([]promptSection{}, defaultSystemPromptSections()...) + sections = append(sections, renderProjectRulesSection(rules)) + sections = append(sections, renderSystemStateSection(systemState)) + return BuildResult{ - SystemPrompt: composeSystemPrompt( - defaultSystemPrompt(), - renderProjectRulesSection(rules), - renderSystemStateSection(systemState), - ), - Messages: trimMessages(input.Messages), + SystemPrompt: composeSystemPrompt(sections...), + Messages: trimMessages(input.Messages), }, nil } diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 4ca49c05..f01a84a7 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -3,6 +3,8 @@ package context import ( stdcontext "context" "fmt" + "os" + "path/filepath" "strings" "testing" @@ -27,8 +29,8 @@ func TestDefaultBuilderBuild(t *testing.T) { if got.SystemPrompt == "" { t.Fatalf("expected non-empty system prompt") } - if !strings.Contains(got.SystemPrompt, defaultSystemPrompt()) { - t.Fatalf("expected default prompt to remain in composed prompt") + if !strings.Contains(got.SystemPrompt, "## Agent Identity") { + t.Fatalf("expected core prompt sections to be included") } if !strings.Contains(got.SystemPrompt, "## System State") { t.Fatalf("expected system state section in composed prompt") @@ -36,6 +38,9 @@ func TestDefaultBuilderBuild(t *testing.T) { if strings.Contains(got.SystemPrompt, "## Project Rules") { t.Fatalf("did not expect project rules section without AGENTS.md") } + if strings.Contains(got.SystemPrompt, "\n\n\n") { + t.Fatalf("did not expect repeated blank lines in composed prompt") + } if !strings.Contains(got.SystemPrompt, input.Metadata.Workdir) { t.Fatalf("expected workdir in system state section") } @@ -60,6 +65,34 @@ func TestDefaultBuilderBuildHonorsCancellation(t *testing.T) { } } +func TestDefaultBuilderBuildComposesPromptSectionsInOrder(t *testing.T) { + t.Parallel() + + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ruleFileName), []byte("project-rules"), 0o644); err != nil { + t.Fatalf("write AGENTS.md: %v", err) + } + + builder := NewBuilder() + got, err := builder.Build(stdcontext.Background(), BuildInput{ + Messages: []provider.Message{{Role: "user", Content: "hello"}}, + Metadata: testMetadata(root), + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + + identityIndex := strings.Index(got.SystemPrompt, "## Agent Identity") + rulesIndex := strings.Index(got.SystemPrompt, "## Project Rules") + stateIndex := strings.Index(got.SystemPrompt, "## System State") + if identityIndex < 0 || rulesIndex < 0 || stateIndex < 0 { + t.Fatalf("expected all prompt sections, got %q", got.SystemPrompt) + } + if !(identityIndex < rulesIndex && rulesIndex < stateIndex) { + t.Fatalf("expected section order core -> project rules -> system state, got %q", got.SystemPrompt) + } +} + func TestTrimMessagesPreservesToolPairs(t *testing.T) { t.Parallel() diff --git a/internal/context/prompt.go b/internal/context/prompt.go index c3c44e2f..c9137b25 100644 --- a/internal/context/prompt.go +++ b/internal/context/prompt.go @@ -2,23 +2,88 @@ package context import "strings" -func defaultSystemPrompt() string { - return `You are NeoCode, a local coding agent. +type promptSection struct { + id string + title string + content string +} - Be concise and accurate. - Use tools when necessary. - When a tool fails, inspect the error and continue safely. - Stay within the workspace and avoid destructive behavior unless clearly requested.` +func defaultSystemPromptSections() []promptSection { + return []promptSection{ + { + id: "agent-identity", + title: "Agent Identity", + content: strings.TrimSpace(` +You are NeoCode, a local coding agent focused on completing the current task end-to-end. +Preserve the main loop of user input, agent reasoning, tool execution, result observation, and UI feedback.`), + }, + { + id: "tool-usage", + title: "Tool Usage", + content: strings.TrimSpace(` +- Use tools when they reduce uncertainty or are required to complete the task safely. +- Inspect tool failures, explain the relevant error, and continue with the safest useful next step. +- Do not claim work is done unless the needed files, commands, or verification actually succeeded.`), + }, + { + id: "workspace-safety", + title: "Workspace Safety", + content: strings.TrimSpace(` +- Stay within the current workspace unless the user clearly asks for something else. +- Avoid destructive actions such as deleting files, rewriting unrelated work, or changing history unless explicitly requested. +- Respect project rules and local constraints before making changes.`), + }, + { + id: "code-changes", + title: "Code Changes", + content: strings.TrimSpace(` +- Prefer minimal, testable changes that keep module boundaries clear. +- Follow the existing architecture and keep provider, runtime, tools, config, and TUI responsibilities separated. +- When behavior changes, update the relevant tests or documentation needed to keep the implementation verifiable.`), + }, + { + id: "failure-recovery", + title: "Failure Recovery", + content: strings.TrimSpace(` +- If blocked, identify the concrete blocker and try the next reasonable path before giving up. +- Surface risky assumptions, partial progress, or missing verification instead of hiding them. +- When constraints prevent completion, return the best safe result and explain what remains.`), + }, + { + id: "response-style", + title: "Response Style", + content: strings.TrimSpace(` +- Be concise, accurate, and collaborative. +- Keep updates focused on useful progress, decisions, and verification. +- Base claims on the current workspace state instead of generic advice.`), + }, + } } -func composeSystemPrompt(parts ...string) string { - sections := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) +func composeSystemPrompt(sections ...promptSection) string { + rendered := make([]string, 0, len(sections)) + for _, section := range sections { + part := renderPromptSection(section) if part == "" { continue } - sections = append(sections, part) + rendered = append(rendered, part) + } + return strings.Join(rendered, "\n\n") +} + +func renderPromptSection(section promptSection) string { + title := strings.TrimSpace(section.title) + content := strings.TrimSpace(section.content) + + switch { + case title == "" && content == "": + return "" + case title == "": + return content + case content == "": + return "" + default: + return "## " + title + "\n\n" + content } - return strings.Join(sections, "\n\n") } diff --git a/internal/context/source_rules.go b/internal/context/source_rules.go index a666132b..562a6295 100644 --- a/internal/context/source_rules.go +++ b/internal/context/source_rules.go @@ -119,15 +119,14 @@ func findExactRuleFile(dir string) (string, error) { return "", nil } -func renderProjectRulesSection(documents []ruleDocument) string { +func renderProjectRulesSection(documents []ruleDocument) promptSection { if len(documents) == 0 { - return "" + return promptSection{} } const totalTruncationNotice = "\n[additional project rules truncated to fit total limit]\n" var builder strings.Builder - builder.WriteString("## Project Rules\n") remaining := maxTotalRuleRunes totalBudgetTruncated := false @@ -162,7 +161,11 @@ func renderProjectRulesSection(documents []ruleDocument) string { } } - return strings.TrimSpace(builder.String()) + return promptSection{ + id: "project-rules", + title: "Project Rules", + content: strings.TrimSpace(builder.String()), + } } func renderRuleDocumentChunk(document ruleDocument) string { diff --git a/internal/context/source_rules_test.go b/internal/context/source_rules_test.go index 12cbda99..fa32a1e7 100644 --- a/internal/context/source_rules_test.go +++ b/internal/context/source_rules_test.go @@ -38,7 +38,7 @@ func TestLoadProjectRulesOrdersGlobalToLocal(t *testing.T) { t.Fatalf("expected global-to-local order, got %+v", documents) } - section := renderProjectRulesSection(documents) + section := renderPromptSection(renderProjectRulesSection(documents)) rootIndex := strings.Index(section, rootRules) localIndex := strings.Index(section, localRules) if rootIndex < 0 || localIndex < 0 || rootIndex >= localIndex { @@ -140,25 +140,29 @@ func TestRenderProjectRulesSectionTruncatesSingleFileAndTotalBudget(t *testing.T largeTotalA := strings.Repeat("b", 7000) largeTotalB := strings.Repeat("c", 7000) - section := renderProjectRulesSection([]ruleDocument{ + section := renderPromptSection(renderProjectRulesSection([]ruleDocument{ {Path: "/repo/AGENTS.md", Content: largeSingle[:maxRuleFileRunes], Truncated: true}, - }) + })) if !strings.Contains(section, "[truncated to fit per-file limit]") { t.Fatalf("expected per-file truncation marker, got %q", section) } - totalSection := renderProjectRulesSection([]ruleDocument{ + totalPromptSection := renderProjectRulesSection([]ruleDocument{ {Path: "/repo/root/AGENTS.md", Content: largeTotalA}, {Path: "/repo/root/app/AGENTS.md", Content: largeTotalB}, }) + totalSection := renderPromptSection(totalPromptSection) if !strings.Contains(totalSection, "[additional project rules truncated to fit total limit]") { t.Fatalf("expected total truncation marker, got %q", totalSection) } if strings.Contains(totalSection, strings.Repeat("c", 6500)) { t.Fatalf("expected total rules section to be truncated") } - body := strings.TrimPrefix(totalSection, "## Project Rules\n") - if runeCount(body) > maxTotalRuleRunes { - t.Fatalf("expected rendered rules body to respect total rune budget, got %d > %d", runeCount(body), maxTotalRuleRunes) + if runeCount(totalPromptSection.content) > maxTotalRuleRunes { + t.Fatalf( + "expected rendered rules body to respect total rune budget, got %d > %d", + runeCount(totalPromptSection.content), + maxTotalRuleRunes, + ) } } diff --git a/internal/context/source_system.go b/internal/context/source_system.go index ebc2a042..8eca85fa 100644 --- a/internal/context/source_system.go +++ b/internal/context/source_system.go @@ -48,10 +48,8 @@ func collectSystemState(ctx context.Context, metadata Metadata, runner gitComman return state, nil } -func renderSystemStateSection(state SystemState) string { +func renderSystemStateSection(state SystemState) promptSection { lines := []string{ - "## System State", - "", fmt.Sprintf("- workdir: `%s`", promptValue(state.Workdir)), fmt.Sprintf("- shell: `%s`", promptValue(state.Shell)), fmt.Sprintf("- provider: `%s`", promptValue(state.Provider)), @@ -68,7 +66,11 @@ func renderSystemStateSection(state SystemState) string { lines = append(lines, "- git: unavailable") } - return strings.Join(lines, "\n") + return promptSection{ + id: "system-state", + title: "System State", + content: strings.Join(lines, "\n"), + } } func runGitCommand(ctx context.Context, workdir string, args ...string) (string, error) { diff --git a/internal/context/source_system_test.go b/internal/context/source_system_test.go index df40ca38..19c1b39e 100644 --- a/internal/context/source_system_test.go +++ b/internal/context/source_system_test.go @@ -21,7 +21,7 @@ func TestCollectSystemStateHandlesGitUnavailable(t *testing.T) { t.Fatalf("expected git to be unavailable") } - section := renderSystemStateSection(state) + section := renderPromptSection(renderSystemStateSection(state)) if !strings.Contains(section, "- git: unavailable") { t.Fatalf("expected unavailable git section, got %q", section) } @@ -56,13 +56,16 @@ func TestCollectSystemStateIncludesGitSummary(t *testing.T) { t.Fatalf("expected dirty git state") } - section := renderSystemStateSection(state) + section := renderPromptSection(renderSystemStateSection(state)) if !strings.Contains(section, "branch=`feature/context`") { t.Fatalf("expected branch in system section, got %q", section) } if !strings.Contains(section, "dirty=`dirty`") { t.Fatalf("expected dirty marker in system section, got %q", section) } + if strings.Contains(section, "internal/context/builder.go") { + t.Fatalf("did not expect full git status output in system section, got %q", section) + } } func TestCollectSystemStateReturnsContextError(t *testing.T) { From 43c8ff6b0514754fc656ad10defa00eaff556aa1 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 2 Apr 2026 15:10:30 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E5=A4=84=E7=90=86=20review=20=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=20prompt=20=E7=BB=86=E8=8A=82=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/runtime-provider-event-flow.md | 2 +- internal/context/prompt.go | 97 ++++++++++++++--------------- internal/context/prompt_test.go | 76 ++++++++++++++++++++++ internal/context/source_rules.go | 1 - internal/context/source_system.go | 1 - 5 files changed, 123 insertions(+), 54 deletions(-) create mode 100644 internal/context/prompt_test.go diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index a439bc59..e9e007e2 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -51,7 +51,7 @@ - 规则文件只支持大写文件名 `AGENTS.md` - 多份命中结果按“从全局到局部”的顺序注入 - git 只注入摘要,不注入完整 `git status` -- 各 section 统一由 `internal/context` 内部 composer 渲染,`runtime` 仍只消费最终字符串 +- 各 section 统一由 `internal/context` 内部的 `renderPromptSection` 和 `composeSystemPrompt` 渲染,`runtime` 仍只消费最终字符串 ## 流式桥接 diff --git a/internal/context/prompt.go b/internal/context/prompt.go index c9137b25..22cc66b7 100644 --- a/internal/context/prompt.go +++ b/internal/context/prompt.go @@ -3,61 +3,50 @@ package context import "strings" type promptSection struct { - id string title string content string } +var defaultPromptSections = []promptSection{ + { + title: "Agent Identity", + content: "You are NeoCode, a local coding agent focused on completing the current task end-to-end.\n" + + "Preserve the main loop of user input, agent reasoning, tool execution, result observation, and UI feedback.", + }, + { + title: "Tool Usage", + content: "- Use tools when they reduce uncertainty or are required to complete the task safely.\n" + + "- Inspect tool failures, explain the relevant error, and continue with the safest useful next step.\n" + + "- Do not claim work is done unless the needed files, commands, or verification actually succeeded.", + }, + { + title: "Workspace Safety", + content: "- Stay within the current workspace unless the user clearly asks for something else.\n" + + "- Avoid destructive actions such as deleting files, rewriting unrelated work, or changing history unless explicitly requested.\n" + + "- Respect project rules and local constraints before making changes.", + }, + { + title: "Code Changes", + content: "- Prefer minimal, testable changes that keep module boundaries clear.\n" + + "- Follow the existing architecture and keep provider, runtime, tools, config, and TUI responsibilities separated.\n" + + "- When behavior changes, update the relevant tests or documentation needed to keep the implementation verifiable.", + }, + { + title: "Failure Recovery", + content: "- If blocked, identify the concrete blocker and try the next reasonable path before giving up.\n" + + "- Surface risky assumptions, partial progress, or missing verification instead of hiding them.\n" + + "- When constraints prevent completion, return the best safe result and explain what remains.", + }, + { + title: "Response Style", + content: "- Be concise, accurate, and collaborative.\n" + + "- Keep updates focused on useful progress, decisions, and verification.\n" + + "- Base claims on the current workspace state instead of generic advice.", + }, +} + func defaultSystemPromptSections() []promptSection { - return []promptSection{ - { - id: "agent-identity", - title: "Agent Identity", - content: strings.TrimSpace(` -You are NeoCode, a local coding agent focused on completing the current task end-to-end. -Preserve the main loop of user input, agent reasoning, tool execution, result observation, and UI feedback.`), - }, - { - id: "tool-usage", - title: "Tool Usage", - content: strings.TrimSpace(` -- Use tools when they reduce uncertainty or are required to complete the task safely. -- Inspect tool failures, explain the relevant error, and continue with the safest useful next step. -- Do not claim work is done unless the needed files, commands, or verification actually succeeded.`), - }, - { - id: "workspace-safety", - title: "Workspace Safety", - content: strings.TrimSpace(` -- Stay within the current workspace unless the user clearly asks for something else. -- Avoid destructive actions such as deleting files, rewriting unrelated work, or changing history unless explicitly requested. -- Respect project rules and local constraints before making changes.`), - }, - { - id: "code-changes", - title: "Code Changes", - content: strings.TrimSpace(` -- Prefer minimal, testable changes that keep module boundaries clear. -- Follow the existing architecture and keep provider, runtime, tools, config, and TUI responsibilities separated. -- When behavior changes, update the relevant tests or documentation needed to keep the implementation verifiable.`), - }, - { - id: "failure-recovery", - title: "Failure Recovery", - content: strings.TrimSpace(` -- If blocked, identify the concrete blocker and try the next reasonable path before giving up. -- Surface risky assumptions, partial progress, or missing verification instead of hiding them. -- When constraints prevent completion, return the best safe result and explain what remains.`), - }, - { - id: "response-style", - title: "Response Style", - content: strings.TrimSpace(` -- Be concise, accurate, and collaborative. -- Keep updates focused on useful progress, decisions, and verification. -- Base claims on the current workspace state instead of generic advice.`), - }, - } + return defaultPromptSections } func composeSystemPrompt(sections ...promptSection) string { @@ -84,6 +73,12 @@ func renderPromptSection(section promptSection) string { case content == "": return "" default: - return "## " + title + "\n\n" + content + var builder strings.Builder + builder.Grow(len(title) + len(content) + len("## \n\n")) + builder.WriteString("## ") + builder.WriteString(title) + builder.WriteString("\n\n") + builder.WriteString(content) + return builder.String() } } diff --git a/internal/context/prompt_test.go b/internal/context/prompt_test.go new file mode 100644 index 00000000..73cd3d15 --- /dev/null +++ b/internal/context/prompt_test.go @@ -0,0 +1,76 @@ +package context + +import "testing" + +func TestDefaultSystemPromptSectionsReturnsCachedSections(t *testing.T) { + t.Parallel() + + sections := defaultSystemPromptSections() + if len(sections) != len(defaultPromptSections) { + t.Fatalf("expected %d default sections, got %d", len(defaultPromptSections), len(sections)) + } + if len(sections) == 0 { + t.Fatalf("expected non-empty default sections") + } + if sections[0].title != "Agent Identity" { + t.Fatalf("expected first default section title, got %q", sections[0].title) + } +} + +func TestRenderPromptSectionBranches(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + section promptSection + want string + }{ + { + name: "empty title and content renders empty", + section: promptSection{}, + want: "", + }, + { + name: "content only renders content", + section: promptSection{ + content: "content only", + }, + want: "content only", + }, + { + name: "title only renders empty", + section: promptSection{ + title: "Title Only", + }, + want: "", + }, + { + name: "title and content render heading", + section: promptSection{ + title: "Section", + content: "body", + }, + want: "## Section\n\nbody", + }, + { + name: "title and content are trimmed before rendering", + section: promptSection{ + title: " Section ", + content: "\nbody\n", + }, + want: "## Section\n\nbody", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := renderPromptSection(tt.section) + if got != tt.want { + t.Fatalf("renderPromptSection() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/context/source_rules.go b/internal/context/source_rules.go index 562a6295..31b89ad1 100644 --- a/internal/context/source_rules.go +++ b/internal/context/source_rules.go @@ -162,7 +162,6 @@ func renderProjectRulesSection(documents []ruleDocument) promptSection { } return promptSection{ - id: "project-rules", title: "Project Rules", content: strings.TrimSpace(builder.String()), } diff --git a/internal/context/source_system.go b/internal/context/source_system.go index 8eca85fa..6ee12e0d 100644 --- a/internal/context/source_system.go +++ b/internal/context/source_system.go @@ -67,7 +67,6 @@ func renderSystemStateSection(state SystemState) promptSection { } return promptSection{ - id: "system-state", title: "System State", content: strings.Join(lines, "\n"), } From 9b58039c9438ceee4e56c5a86e744148d5ae391b Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 2 Apr 2026 22:30:13 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=EF=BB=BFfeat(compact):=20=E5=AE=8C?= =?UTF-8?q?=E6=88=90=20M1=20=E6=94=B6=E5=8F=A3=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E9=A3=8E=E9=99=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 默认启用 micro_compact,新增 manual full_replace 策略 - 统一 compact 流程并补齐 transcript 持久化与唯一命名 - 修复 micro_enabled=false 持久化丢失问题,兼容缺失字段回退默认值 - 调整 TUI compact 轻量可观测,避免 micro 失败污染主流程错误态 - 同步配置/事件/持久化相关文档并补齐回归测试 测试: - go test ./... --- .gitignore | 1 + README.md | 3 + docs/config-management-detail-design.md | 11 + docs/runtime-provider-event-flow.md | 31 +- docs/session-persistence-design.md | 6 + docs/tools-and-tui-integration.md | 2 + internal/config/config_test.go | 178 +++++++++ internal/config/loader.go | 64 ++- internal/config/model.go | 123 +++++- internal/context/compact/runner.go | 500 ++++++++++++++++++++++++ internal/context/compact/runner_test.go | 337 ++++++++++++++++ internal/runtime/events.go | 8 + internal/runtime/runtime.go | 145 ++++++- internal/runtime/runtime_test.go | 231 +++++++++++ internal/tui/commands.go | 4 + internal/tui/commands_test.go | 1 + internal/tui/update.go | 85 +++- internal/tui/update_test.go | 147 ++++++- 18 files changed, 1844 insertions(+), 33 deletions(-) create mode 100644 internal/context/compact/runner.go create mode 100644 internal/context/compact/runner_test.go 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 bd65dfe8..17313766 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 97a677b9..70fa4761 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -812,6 +812,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 5973f662..365a1508 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 { @@ -122,6 +136,7 @@ func (l *Loader) Save(ctx context.Context, cfg *Config) error { Shell: snapshot.Shell, MaxLoops: snapshot.MaxLoops, ToolTimeoutSec: snapshot.ToolTimeoutSec, + Context: newPersistedContextConfig(snapshot.Context), Tools: snapshot.Tools, } @@ -162,7 +177,7 @@ type aliasConfig struct { WorkspaceRoot string `yaml:"workspace_root"` } -func parseCurrentConfig(data []byte, _ Config) (*Config, error) { +func parseCurrentConfig(data []byte, defaults Config) (*Config, error) { var file persistedConfig if err := yaml.Unmarshal(data, &file); err != nil { return nil, err @@ -185,12 +200,45 @@ func parseCurrentConfig(data []byte, _ Config) (*Config, error) { Shell: strings.TrimSpace(file.Shell), MaxLoops: file.MaxLoops, ToolTimeoutSec: file.ToolTimeoutSec, + Context: fromPersistedContextConfig(file.Context, defaults.Context), Tools: file.Tools, } return cfg, 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 requiresConfigRewrite(data []byte) bool { text := strings.TrimSpace(string(data)) switch { diff --git a/internal/config/model.go b/internal/config/model.go index c0b57f0b..274ba096 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"` } @@ -55,6 +65,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"` @@ -70,6 +93,7 @@ func Default() *Config { Shell: defaultShell(), MaxLoops: DefaultMaxLoops, ToolTimeoutSec: DefaultToolTimeoutSec, + Context: defaultContextConfig(), Tools: ToolsConfig{ WebFetch: defaultWebFetchConfig(), }, @@ -83,6 +107,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 } @@ -123,6 +148,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) @@ -174,6 +200,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 } @@ -355,12 +384,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 @@ -369,6 +421,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) @@ -376,12 +436,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 @@ -393,6 +464,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") @@ -409,6 +502,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/compact/runner.go b/internal/context/compact/runner.go new file mode 100644 index 00000000..2702892d --- /dev/null +++ b/internal/context/compact/runner.go @@ -0,0 +1,500 @@ +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" +) + +type Mode string + +const ( + ModeMicro Mode = "micro" + ModeManual Mode = "manual" +) + +type ErrorMode string + +const ( + ErrorModeNone ErrorMode = "none" +) + +type Input struct { + Mode Mode + SessionID string + Workdir string + Messages []provider.Message + Config config.CompactConfig +} + +type Metrics struct { + BeforeChars int `json:"before_chars"` + AfterChars int `json:"after_chars"` + SavedRatio float64 `json:"saved_ratio"` + TriggerMode string `json:"trigger_mode"` +} + +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"` +} + +type Runner interface { + Run(ctx context.Context, input Input) (Result, error) +} + +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 +} + +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, + } +} + +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), + }, + } + + 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 +} + +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 +} + +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 +} + +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 +} + +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 +} + +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) + } +} + +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 +} + +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 +} + +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) +} + +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 +} + +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) +} + +type transcriptLine struct { + Index int `json:"index"` + Timestamp string `json:"timestamp"` + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []provider.ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + IsError bool `json:"is_error,omitempty"` +} + +func (s *Service) saveTranscript(messages []provider.Message, sessionID string, workdir string) (string, string, error) { + home, err := s.userHomeDir() + if err != nil { + return "", "", fmt.Errorf("compact: resolve user home: %w", err) + } + + projectHash := hashProject(workdir) + dir := 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 +} + +func transcriptFileMode() os.FileMode { + if goruntime.GOOS == "windows" { + return 0o644 + } + return 0o600 +} + +func randomTranscriptToken() (string, error) { + entropy := make([]byte, 4) + if _, err := cryptorand.Read(entropy); err != nil { + return "", err + } + return hex.EncodeToString(entropy), nil +} + +func hashProject(workdir string) string { + clean := strings.TrimSpace(filepath.Clean(workdir)) + if clean == "" { + clean = "unknown" + } + sum := sha1.Sum([]byte(strings.ToLower(clean))) + return hex.EncodeToString(sum[:8]) +} + +var nonIDChars = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) + +func sanitizeID(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + return nonIDChars.ReplaceAllString(value, "_") +} + +func cloneMessages(messages []provider.Message) []provider.Message { + if len(messages) == 0 { + return nil + } + out := make([]provider.Message, 0, len(messages)) + for _, message := range messages { + next := message + next.ToolCalls = append([]provider.ToolCall(nil), message.ToolCalls...) + out = append(out, next) + } + return out +} + +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 +} + +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..8b1608f5 --- /dev/null +++ b/internal/context/compact/runner_test.go @@ -0,0 +1,337 @@ +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) + } +} diff --git a/internal/runtime/events.go b/internal/runtime/events.go index 0e2ce006..27cd73b7 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -36,4 +36,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..1fe8cef1 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: string(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: result.Metrics.TriggerMode, + 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 diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index f75a4cf8..9ceba70e 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -11,6 +11,7 @@ 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" @@ -203,6 +204,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,6 +1249,204 @@ 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) @@ -1312,6 +1528,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 { diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 18dfea81..094d4dc0 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" @@ -56,6 +58,7 @@ const ( statusApplyingCommand = "Applying local command" statusRunningCommand = "Running command" statusCommandDone = "Command finished" + statusCompacting = "Compacting context" statusChooseProvider = "Choose a provider" statusChooseModel = "Choose a model" @@ -102,6 +105,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 58747261..a37e44d6 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -21,6 +21,9 @@ import ( type RuntimeMsg struct{ Event agentruntime.RuntimeEvent } type RuntimeClosedMsg struct{} type runFinishedMsg struct{ err error } +type compactFinishedMsg struct { + err error +} type localCommandResultMsg struct { notice string err error @@ -78,6 +81,26 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.syncActiveSessionTitle() a.rebuildTranscript() 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() @@ -474,6 +497,47 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) { a.state.StatusText = payload a.appendInlineMessage(roleError, payload) } + case agentruntime.EventCompactDone: + payload, ok := event.Payload.(agentruntime.CompactDonePayload) + if !ok { + return + } + // Skip passive micro checks that did not rewrite context to avoid transcript noise. + if payload.TriggerMode == "micro" && !payload.Applied { + return + } + 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, + ), + ) + case agentruntime.EventCompactError: + payload, ok := event.Payload.(agentruntime.CompactErrorPayload) + if !ok { + return + } + if payload.TriggerMode == "micro" { + // 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)) + return + } + message := fmt.Sprintf("Compact(%s) failed: %s", payload.TriggerMode, payload.Message) + a.state.ExecutionError = message + a.state.StatusText = message + a.appendInlineMessage(roleError, message) } } @@ -649,7 +713,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 @@ -657,6 +721,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 } @@ -717,3 +793,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 f9d7e061..5220507a 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 } @@ -1133,6 +1141,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: "manual", + 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: "micro", + }, + }, + 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: "manual", + 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: "micro", + 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 { @@ -1205,6 +1300,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")