From f27b4ba91f589060318b897e8b94c7556b31161a Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 10 May 2026 14:45:59 +0800 Subject: [PATCH 1/2] fix(context): refine micro compact retention --- internal/context/builder_test.go | 12 +- internal/context/microcompact.go | 57 ++++-- .../context/microcompact_summarizer_test.go | 61 ++++-- internal/context/microcompact_test.go | 191 ++++++++++++++++-- internal/context/pin_checker.go | 35 +++- internal/context/pin_checker_test.go | 30 +++ internal/context/projection.go | 58 +++++- internal/context/projection_test.go | 16 +- internal/context/types.go | 2 +- internal/tools/codebase/read.go | 2 +- internal/tools/codebase/read_test.go | 4 +- internal/tools/format.go | 2 + internal/tools/format_test.go | 21 ++ .../tools/micro_compact_summarizer_test.go | 2 +- .../micro_compact_summarizers_builtin.go | 1 + internal/tools/spawnsubagent/tool.go | 4 +- internal/tools/spawnsubagent/tool_test.go | 4 +- 17 files changed, 427 insertions(+), 75 deletions(-) diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index f0273319..d7bb2122 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -399,8 +399,8 @@ func TestDefaultBuilderBuildAppliesMicroCompactAfterTrim(t *testing.T) { if len(got.Messages) != len(messages) { t.Fatalf("expected builder output to keep message count, got %d want %d", len(got.Messages), len(messages)) } - if renderDisplayParts(got.Messages[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected builder output to clear older tool result, got %q", renderDisplayParts(got.Messages[2].Parts)) + if !strings.Contains(renderDisplayParts(got.Messages[2].Parts), "[summary] filesystem_read_file") { + t.Fatalf("expected builder output to summarize older tool result, got %q", renderDisplayParts(got.Messages[2].Parts)) } if renderDisplayParts(got.Messages[4].Parts) != "recent bash result" { t.Fatalf("expected recent tool result to stay visible, got %q", renderDisplayParts(got.Messages[4].Parts)) @@ -513,8 +513,8 @@ func TestDefaultBuilderBuildRespectsExplicitPinCheckerOverride(t *testing.T) { if err != nil { t.Fatalf("Build() error = %v", err) } - if renderDisplayParts(got.Messages[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected explicit noop pin checker to allow compaction, got %q", renderDisplayParts(got.Messages[2].Parts)) + if !strings.Contains(renderDisplayParts(got.Messages[2].Parts), "[summary] filesystem_write_file") { + t.Fatalf("expected explicit noop pin checker to allow compaction into summary, got %q", renderDisplayParts(got.Messages[2].Parts)) } } @@ -1116,8 +1116,8 @@ func TestNewConfiguredBuilder(t *testing.T) { if err != nil { t.Fatalf("Build() error = %v", err) } - if renderDisplayParts(got.Messages[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected noop pin checker to allow compaction, got %q", renderDisplayParts(got.Messages[2].Parts)) + if !strings.Contains(renderDisplayParts(got.Messages[2].Parts), "[summary] filesystem_write_file") { + t.Fatalf("expected noop pin checker to allow compaction into summary, got %q", renderDisplayParts(got.Messages[2].Parts)) } }) diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index 399c34ef..050a543e 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -1,7 +1,9 @@ package context import ( + "strconv" "strings" + "unicode/utf8" "neo-code/internal/config" "neo-code/internal/context/internalcompact" @@ -235,32 +237,63 @@ func summarizeOrClear( toolNames map[string]string, summarizers MicroCompactSummarizerSource, ) string { - if summarizers == nil { - return microCompactClearedMessage - } - callID := strings.TrimSpace(message.ToolCallID) toolName, ok := toolNames[callID] if !ok { return microCompactClearedMessage } - summarizer := summarizers.MicroCompactSummarizer(toolName) - if summarizer == nil { - return microCompactClearedMessage + if summarizers != nil { + summarizer := summarizers.MicroCompactSummarizer(toolName) + if summarizer != nil { + summary := summarizer(content, message.ToolMetadata, message.IsError) + if summary != "" { + summary = sanitizeMicroCompactSummary(summary) + if summary != "" { + return summary + } + } + } } - summary := summarizer(content, message.ToolMetadata, message.IsError) - if summary == "" { - return microCompactClearedMessage - } - summary = sanitizeMicroCompactSummary(summary) + summary := sanitizeMicroCompactSummary(fallbackSummary(toolName, content)) if summary == "" { return microCompactClearedMessage } return summary } +// fallbackSummary 为缺少专用摘要器的工具生成最小可读摘要,避免静默清空历史。 +func fallbackSummary(toolName string, content string) string { + trimmedName := strings.TrimSpace(toolName) + if trimmedName == "" { + return "" + } + + parts := []string{ + "[summary]", + trimmedName, + "lines=" + strconv.Itoa(stableLineCount(content)), + "chars=" + strconv.Itoa(utf8.RuneCountInString(content)), + } + return strings.Join(parts, " ") +} + +// stableLineCount 统计文本行数;空文本返回 0,末尾换行不会产生额外空行计数。 +func stableLineCount(text string) int { + if text == "" { + return 0 + } + count := strings.Count(text, "\n") + 1 + if strings.HasSuffix(text, "\n") { + count-- + } + if count < 0 { + return 0 + } + return count +} + // sanitizeMicroCompactSummary 对 summarizer 输出做最终净化与限长,避免把不安全文本直接回灌上下文。 func sanitizeMicroCompactSummary(summary string) string { trimmed := strings.TrimSpace(summary) diff --git a/internal/context/microcompact_summarizer_test.go b/internal/context/microcompact_summarizer_test.go index 949edb4e..e1b010e8 100644 --- a/internal/context/microcompact_summarizer_test.go +++ b/internal/context/microcompact_summarizer_test.go @@ -78,8 +78,8 @@ func TestMicroCompactWithSummarizerProducesSummary(t *testing.T) { } } -// TestMicroCompactWithoutSummarizerFallsBackToClear 验证未注册 summarizer 的工具仍使用清除占位。 -func TestMicroCompactWithoutSummarizerFallsBackToClear(t *testing.T) { +// TestMicroCompactWithoutSummarizerFallsBackToSummary 验证未注册 summarizer 的工具使用通用兜底摘要。 +func TestMicroCompactWithoutSummarizerFallsBackToSummary(t *testing.T) { t.Parallel() messages := []providertypes.Message{ @@ -121,9 +121,12 @@ func TestMicroCompactWithoutSummarizerFallsBackToClear(t *testing.T) { nil, ) - // read_file 没有 summarizer,应回退到清除 - if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected cleared placeholder for read_file without summarizer, got %q", renderDisplayParts(got[2].Parts)) + summary := renderDisplayParts(got[2].Parts) + if summary == microCompactClearedMessage { + t.Fatalf("expected fallback summary for read_file without summarizer, got cleared placeholder") + } + if !strings.Contains(summary, "[summary] filesystem_read_file") { + t.Fatalf("expected fallback summary to include tool name, got %q", summary) } } @@ -176,14 +179,17 @@ func TestMicroCompactMixedSpanWithSummarizer(t *testing.T) { if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary]") { t.Fatalf("expected bash summary in old span, got %q", renderDisplayParts(got[2].Parts)) } - // call-2 read_file 在旧 span,没有 summarizer,应清除 - if renderDisplayParts(got[3].Parts) != microCompactClearedMessage { - t.Fatalf("expected read_file cleared in old span, got %q", renderDisplayParts(got[3].Parts)) + summary := renderDisplayParts(got[3].Parts) + if summary == microCompactClearedMessage { + t.Fatalf("expected read_file fallback summary in old span, got cleared placeholder") + } + if !strings.Contains(summary, "[summary] filesystem_read_file") { + t.Fatalf("expected read_file fallback summary to include tool name, got %q", summary) } } -// TestMicroCompactSummarizerReturnsEmptyFallsBackToClear 验证 summarizer 返回空字符串时回退到清除。 -func TestMicroCompactSummarizerReturnsEmptyFallsBackToClear(t *testing.T) { +// TestMicroCompactSummarizerReturnsEmptyFallsBackToSummary 验证 summarizer 返回空字符串时回退到通用摘要。 +func TestMicroCompactSummarizerReturnsEmptyFallsBackToSummary(t *testing.T) { t.Parallel() messages := []providertypes.Message{ @@ -224,8 +230,12 @@ func TestMicroCompactSummarizerReturnsEmptyFallsBackToClear(t *testing.T) { nil, ) - if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected cleared fallback when summarizer returns empty, got %q", renderDisplayParts(got[2].Parts)) + summary := renderDisplayParts(got[2].Parts) + if summary == microCompactClearedMessage { + t.Fatalf("expected fallback summary when summarizer returns empty, got cleared placeholder") + } + if !strings.Contains(summary, "[summary] bash") { + t.Fatalf("expected fallback summary to include tool name, got %q", summary) } } @@ -244,6 +254,26 @@ func TestSummarizeOrClearWithNilSummarizers(t *testing.T) { } } +func TestSummarizeOrClearFallsBackWithoutRegisteredSummarizer(t *testing.T) { + t.Parallel() + + got := summarizeOrClear( + providertypes.Message{ToolCallID: "call-1"}, + "first line\nsecond line", + map[string]string{"call-1": "mcp.github.issue"}, + nil, + ) + if got == microCompactClearedMessage { + t.Fatalf("expected fallback summary for MCP tool, got cleared placeholder") + } + if !strings.Contains(got, "[summary] mcp.github.issue") { + t.Fatalf("expected MCP tool name in fallback summary, got %q", got) + } + if !strings.Contains(got, "lines=2") { + t.Fatalf("expected line count in fallback summary, got %q", got) + } +} + // TestSummarizeOrClearWithToolNamesLookup 验证 toolNames map 查找工具名。 func TestSummarizeOrClearWithToolNamesLookup(t *testing.T) { t.Parallel() @@ -321,8 +351,11 @@ func TestSummarizeOrClearSanitizationEmptyFallback(t *testing.T) { }, ) - if got != microCompactClearedMessage { - t.Fatalf("expected cleared fallback when sanitized summary is empty, got %q", got) + if got == microCompactClearedMessage { + t.Fatalf("expected fallback summary when sanitized summary is empty, got cleared placeholder") + } + if !strings.Contains(got, "[summary] bash") { + t.Fatalf("expected fallback summary to include tool name, got %q", got) } } diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go index cdada98a..5b1bdf54 100644 --- a/internal/context/microcompact_test.go +++ b/internal/context/microcompact_test.go @@ -51,8 +51,8 @@ func TestMicroCompactMessagesClearsOlderCompactableToolResults(t *testing.T) { if len(got) != len(messages) { t.Fatalf("expected message count to stay unchanged, got %d want %d", len(got), len(messages)) } - if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected oldest compactable tool result to be cleared, got %q", renderDisplayParts(got[2].Parts)) + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] filesystem_read_file") { + t.Fatalf("expected oldest compactable tool result to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) } if renderDisplayParts(got[4].Parts) != "recent bash result" { t.Fatalf("expected recent compactable tool result to be retained, got %q", renderDisplayParts(got[4].Parts)) @@ -123,8 +123,8 @@ func TestMicroCompactMessagesKeepsProtectedTailUntouched(t *testing.T) { } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 2, nil, nil) - if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected old tool result before protected tail to be cleared, got %q", renderDisplayParts(got[2].Parts)) + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] filesystem_grep") { + t.Fatalf("expected old tool result before protected tail to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) } if renderDisplayParts(got[4].Parts) != "recent read result" { t.Fatalf("expected recent tool result before protected tail to remain, got %q", renderDisplayParts(got[4].Parts)) @@ -227,8 +227,8 @@ func TestMicroCompactMessagesClearsOnlyNonPreservedResultsInMixedToolSpan(t *tes got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ "custom_tool": tools.MicroCompactPolicyPreserveHistory, }, 2, nil, nil) - if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected default compactable tool result to be cleared, got %q", renderDisplayParts(got[2].Parts)) + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] filesystem_read_file") { + t.Fatalf("expected default compactable tool result to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) } if renderDisplayParts(got[3].Parts) != "custom result" { t.Fatalf("expected preserved tool result in mixed span to remain, got %q", renderDisplayParts(got[3].Parts)) @@ -268,8 +268,80 @@ func TestMicroCompactMessagesTreatsNewToolsAsCompactableByDefault(t *testing.T) } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 2, nil, nil) - if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected new tool result to be compacted by default, got %q", renderDisplayParts(got[2].Parts)) + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] repo_search") { + t.Fatalf("expected new tool result to be compacted into fallback summary by default, got %q", renderDisplayParts(got[2].Parts)) + } +} + +func TestMicroCompactMessagesPreservesSpawnSubAgentHistory(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: tools.ToolNameSpawnSubAgent, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("spawned analysis")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-2", Name: tools.ToolNameBash, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-3", Name: tools.ToolNameWebFetch, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + } + + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ + tools.ToolNameSpawnSubAgent: tools.MicroCompactPolicyPreserveHistory, + }, 1, nil, nil) + if renderDisplayParts(got[2].Parts) != "spawned analysis" { + t.Fatalf("expected spawn_subagent history to be preserved, got %q", renderDisplayParts(got[2].Parts)) + } +} + +func TestMicroCompactMessagesCompactsCodebaseReadToSummary(t *testing.T) { + t.Parallel() + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: tools.ToolNameCodebaseRead, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-1", Parts: []providertypes.ContentPart{providertypes.NewTextPart("path: main.go\n\npackage main")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-2", Name: tools.ToolNameBash, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-2", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "call-3", Name: tools.ToolNameWebFetch, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "call-3", Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest webfetch result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + } + + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 2, nil, nil) + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] codebase_read") { + t.Fatalf("expected codebase_read history to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) } } @@ -318,8 +390,8 @@ func TestMicroCompactMessagesSkipsEmptyRecentSpansWhenCountingRetainedBudget(t * } got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 2, nil, nil) - if renderDisplayParts(got[2].Parts) != microCompactClearedMessage { - t.Fatalf("expected oldest valid tool result to be cleared, got %q", renderDisplayParts(got[2].Parts)) + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] filesystem_read_file") { + t.Fatalf("expected oldest valid tool result to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) } if renderDisplayParts(got[4].Parts) != "middle grep result" { t.Fatalf("expected middle valid tool result to remain, got %q", renderDisplayParts(got[4].Parts)) @@ -423,8 +495,103 @@ func TestMicroCompactMixedPinnedAndNonPinned(t *testing.T) { if renderDisplayParts(got[2].Parts) != "README content" { t.Fatalf("expected pinned README result preserved, got %q", renderDisplayParts(got[2].Parts)) } - if renderDisplayParts(got[3].Parts) != microCompactClearedMessage { - t.Fatalf("expected non-pinned main.go result to be cleared, got %q", renderDisplayParts(got[3].Parts)) + if !strings.Contains(renderDisplayParts(got[3].Parts), "[summary] filesystem_write_file") { + t.Fatalf("expected non-pinned main.go result to fall back to summary, got %q", renderDisplayParts(got[3].Parts)) + } +} + +func TestMicroCompactPinsCopyAndMoveUsingPersistedMetadataPaths(t *testing.T) { + t.Parallel() + + pinChecker := NewDefaultPinChecker() + + copyMessages := []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "copy-call", Name: tools.ToolNameFilesystemCopyFile, Arguments: `{"source_path":"main.go","destination_path":"go.mod"}`}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "copy-call", Parts: []providertypes.ContentPart{providertypes.NewTextPart("ok")}, ToolMetadata: map[string]string{ + "tool_name": tools.ToolNameFilesystemCopyFile, + "source_path": "/project/main.go", + "destination_path": "/project/go.mod", + }}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "recent-call", Name: tools.ToolNameBash, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "recent-call", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + } + + copyGot := microCompactMessagesWithPolicies(copyMessages, stubMicroCompactPolicySource{}, 1, nil, pinChecker) + if renderDisplayParts(copyGot[2].Parts) != "ok" { + t.Fatalf("expected copy_file result touching go.mod to stay pinned, got %q", renderDisplayParts(copyGot[2].Parts)) + } + + moveMessages := []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "move-call", Name: tools.ToolNameFilesystemMoveFile, Arguments: `{"source_path":"package.json","destination_path":"package.backup.json"}`}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "move-call", Parts: []providertypes.ContentPart{providertypes.NewTextPart("ok")}, ToolMetadata: map[string]string{ + "tool_name": tools.ToolNameFilesystemMoveFile, + "source_path": "/project/package.json", + "destination_path": "/project/package.backup.json", + }}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "recent-call", Name: tools.ToolNameBash, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "recent-call", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + } + + moveGot := microCompactMessagesWithPolicies(moveMessages, stubMicroCompactPolicySource{}, 1, nil, pinChecker) + if renderDisplayParts(moveGot[2].Parts) != "ok" { + t.Fatalf("expected move_file result touching package.json to stay pinned, got %q", renderDisplayParts(moveGot[2].Parts)) + } +} + +func TestMicroCompactStillCompactsCopyAndMoveWhenNoKeyFileIsTouched(t *testing.T) { + t.Parallel() + + pinChecker := NewDefaultPinChecker() + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("older user")}}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "move-call", Name: tools.ToolNameFilesystemMoveFile, Arguments: `{"source_path":"main.go","destination_path":"main2.go"}`}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "move-call", Parts: []providertypes.ContentPart{providertypes.NewTextPart("ok")}, ToolMetadata: map[string]string{ + "tool_name": tools.ToolNameFilesystemMoveFile, + "source_path": "/project/main.go", + "destination_path": "/project/main2.go", + }}, + { + Role: providertypes.RoleAssistant, + ToolCalls: []providertypes.ToolCall{ + {ID: "recent-call", Name: tools.ToolNameBash, Arguments: "{}"}, + }, + }, + {Role: providertypes.RoleTool, ToolCallID: "recent-call", Parts: []providertypes.ContentPart{providertypes.NewTextPart("recent bash result")}}, + {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, + } + + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 1, nil, pinChecker) + if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] filesystem_move_file") { + t.Fatalf("expected non-key move_file result to still compact into summary, got %q", renderDisplayParts(got[2].Parts)) } } diff --git a/internal/context/pin_checker.go b/internal/context/pin_checker.go index 3377a19a..550c3835 100644 --- a/internal/context/pin_checker.go +++ b/internal/context/pin_checker.go @@ -23,6 +23,8 @@ var defaultPinPatterns = []string{ var defaultPinToolNames = map[string]struct{}{ tools.ToolNameFilesystemWriteFile: {}, tools.ToolNameFilesystemEdit: {}, + tools.ToolNameFilesystemCopyFile: {}, + tools.ToolNameFilesystemMoveFile: {}, } // pinChecker 基于文件路径 glob 模式判断工具结果是否应钉住。 @@ -44,21 +46,32 @@ func (p *pinChecker) ShouldPin(toolName string, metadata map[string]string) bool return false } - path := metadata["relative_path"] - if path == "" { - path = metadata["path"] - } - if path == "" { - return false + for _, path := range candidatePinPaths(metadata) { + base := filepath.Base(path) + for _, pattern := range p.patterns { + if matched, _ := filepath.Match(pattern, base); matched { + return true + } + } } + return false +} - base := filepath.Base(path) - for _, pattern := range p.patterns { - if matched, _ := filepath.Match(pattern, base); matched { - return true +// candidatePinPaths 按稳定顺序提取可参与 pin 判断的文件路径字段。 +func candidatePinPaths(metadata map[string]string) []string { + keys := []string{"relative_path", "path", "source_path", "destination_path"} + paths := make([]string, 0, len(keys)) + for _, key := range keys { + path := strings.TrimSpace(metadata[key]) + if path == "" { + continue } + paths = append(paths, path) } - return false + if len(paths) == 0 { + return nil + } + return paths } // toolSupportsPinnedRetention 判断工具是否允许参与默认 pin 策略,避免非文件修改类工具扩大保留范围。 diff --git a/internal/context/pin_checker_test.go b/internal/context/pin_checker_test.go index f4d9a34f..7bc9cc5a 100644 --- a/internal/context/pin_checker_test.go +++ b/internal/context/pin_checker_test.go @@ -39,6 +39,8 @@ func TestDefaultPinCheckerMatchesKeyArtifacts(t *testing.T) { {toolName: "filesystem_write_file", path: "utils.py", expected: false}, {toolName: "filesystem_write_file", path: "style.css", expected: false}, {toolName: "filesystem_edit", path: "README.md", expected: true}, + {toolName: "filesystem_copy_file", path: "go.mod", expected: true}, + {toolName: "filesystem_move_file", path: "package.json", expected: true}, {toolName: "filesystem_read_file", path: "README.md", expected: false}, {toolName: "bash", path: "README.md", expected: false}, } @@ -78,6 +80,34 @@ func TestDefaultPinCheckerFallsBackToPath(t *testing.T) { } } +func TestDefaultPinCheckerSupportsCopyAndMoveMetadataFields(t *testing.T) { + t.Parallel() + + checker := NewDefaultPinChecker() + + copyPinned := checker.ShouldPin("filesystem_copy_file", map[string]string{ + "destination_path": "/project/go.mod", + }) + if !copyPinned { + t.Error("expected destination_path match for copy_file go.mod") + } + + movePinned := checker.ShouldPin("filesystem_move_file", map[string]string{ + "source_path": "/project/package.json", + }) + if !movePinned { + t.Error("expected source_path match for move_file package.json") + } + + notPinned := checker.ShouldPin("filesystem_move_file", map[string]string{ + "source_path": "/project/main.go", + "destination_path": "/project/main2.go", + }) + if notPinned { + t.Error("expected non-key source/destination paths to remain unpinned") + } +} + func TestDefaultPinCheckerNoPathReturnsFalse(t *testing.T) { t.Parallel() diff --git a/internal/context/projection.go b/internal/context/projection.go index 265beb0c..9db200df 100644 --- a/internal/context/projection.go +++ b/internal/context/projection.go @@ -10,9 +10,12 @@ import ( const ( recentWindowAbsoluteMessageLimit = 24 - recentWindowToolContentCharLimit = 600 + recentWindowToolContentHeadChars = 300 + recentWindowToolContentTailChars = 300 ) +const truncatedExcerptMarker = "\n...[truncated]...\n" + // ProjectToolMessagesForModel 原地投影 tool 消息,复用主链路对模型可见的只读格式化规则。 func ProjectToolMessagesForModel(messages []providertypes.Message) []providertypes.Message { for i := range messages { @@ -229,7 +232,7 @@ func sanitizeProjectedToolContent(content string) string { return prefix } - limited, truncated := truncateUTF8(body, recentWindowToolContentCharLimit) + limited, truncated := sanitizeToolExcerpt(body) lines := []string{prefix, "content_excerpt:", limited} if truncated { lines = append(lines, "[content truncated for memo extraction]") @@ -243,7 +246,7 @@ func sanitizeRawToolContent(content string) string { if body == "" { return "" } - limited, truncated := truncateUTF8(body, recentWindowToolContentCharLimit) + limited, truncated := sanitizeToolExcerpt(body) if !truncated { return body } @@ -254,21 +257,58 @@ func sanitizeRawToolContent(content string) string { }, "\n") } -// truncateUTF8 按 rune 数量截断字符串,返回截断后的文本及是否发生截断。 -func truncateUTF8(text string, maxRunes int) (string, bool) { +// sanitizeToolExcerpt 保留工具输出的头尾窗口,避免 memo 提取遗漏尾部关键错误。 +func sanitizeToolExcerpt(text string) (string, bool) { + total := utf8.RuneCountInString(text) + limit := recentWindowToolContentHeadChars + recentWindowToolContentTailChars + if limit <= 0 || text == "" { + return "", total > 0 + } + if total <= limit { + return text, false + } + + head := headRunes(text, recentWindowToolContentHeadChars) + tail := tailRunes(text, recentWindowToolContentTailChars) + return head + truncatedExcerptMarker + tail, true +} + +// headRunes 返回文本前 maxRunes 个 rune。 +func headRunes(text string, maxRunes int) string { if maxRunes <= 0 || text == "" { - return "", text != "" + return "" } if utf8.RuneCountInString(text) <= maxRunes { - return text, false + return text } count := 0 for index := range text { if count == maxRunes { - return text[:index], true + return text[:index] + } + count++ + } + return text +} + +// tailRunes 返回文本后 maxRunes 个 rune。 +func tailRunes(text string, maxRunes int) string { + if maxRunes <= 0 || text == "" { + return "" + } + total := utf8.RuneCountInString(text) + if total <= maxRunes { + return text + } + + startRune := total - maxRunes + count := 0 + for index := range text { + if count == startRune { + return text[index:] } count++ } - return text, false + return text } diff --git a/internal/context/projection_test.go b/internal/context/projection_test.go index 80edb854..8e78a870 100644 --- a/internal/context/projection_test.go +++ b/internal/context/projection_test.go @@ -229,7 +229,7 @@ func TestBuildRecentMessagesForModelRespectsAbsoluteMessageBudget(t *testing.T) func TestSanitizeProjectedToolContent(t *testing.T) { t.Parallel() - rawBody := strings.Repeat("甲", recentWindowToolContentCharLimit+10) + rawBody := strings.Repeat("A", recentWindowToolContentHeadChars+10) + strings.Repeat("B", recentWindowToolContentTailChars-20) + "TAIL-MARKER" projected := "tool result\nstatus: ok\n\ncontent:\n" + rawBody sanitized := sanitizeProjectedToolContent(projected) if !strings.Contains(sanitized, "content_excerpt:") { @@ -238,6 +238,12 @@ func TestSanitizeProjectedToolContent(t *testing.T) { if strings.Contains(sanitized, "content:\n") { t.Fatalf("expected original content marker removed, got %q", sanitized) } + if !strings.Contains(sanitized, "...[truncated]...") { + t.Fatalf("expected middle truncation marker, got %q", sanitized) + } + if !strings.Contains(sanitized, "TAIL-MARKER") { + t.Fatalf("expected tail content to be preserved, got %q", sanitized) + } if !strings.Contains(sanitized, "[content truncated for memo extraction]") { t.Fatalf("expected truncation marker, got %q", sanitized) } @@ -390,11 +396,17 @@ func TestIsInjectableToolMessage(t *testing.T) { func TestSanitizeProjectedToolContentFallsBackForRawPayload(t *testing.T) { t.Parallel() - raw := strings.Repeat("x", recentWindowToolContentCharLimit+10) + raw := strings.Repeat("x", recentWindowToolContentHeadChars+20) + strings.Repeat("y", recentWindowToolContentTailChars-10) + "RAW-TAIL" sanitized := sanitizeProjectedToolContent(raw) if !strings.Contains(sanitized, "content_excerpt:") { t.Fatalf("expected raw payload to be excerpted, got %q", sanitized) } + if !strings.Contains(sanitized, "RAW-TAIL") { + t.Fatalf("expected raw payload tail to be preserved, got %q", sanitized) + } + if !strings.Contains(sanitized, "...[truncated]...") { + t.Fatalf("expected middle truncation marker, got %q", sanitized) + } if !strings.Contains(sanitized, "[content truncated for memo extraction]") { t.Fatalf("expected truncation marker, got %q", sanitized) } diff --git a/internal/context/types.go b/internal/context/types.go index 15ed99ea..94aceb44 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -3,8 +3,8 @@ package context import ( "context" - "neo-code/internal/repository" providertypes "neo-code/internal/provider/types" + "neo-code/internal/repository" agentsession "neo-code/internal/session" "neo-code/internal/skills" "neo-code/internal/tools" diff --git a/internal/tools/codebase/read.go b/internal/tools/codebase/read.go index f76476e1..0a6db819 100644 --- a/internal/tools/codebase/read.go +++ b/internal/tools/codebase/read.go @@ -50,7 +50,7 @@ func (t *ReadTool) Schema() map[string]any { } func (t *ReadTool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyPreserveHistory + return tools.MicroCompactPolicyCompact } func (t *ReadTool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { diff --git a/internal/tools/codebase/read_test.go b/internal/tools/codebase/read_test.go index ba29f03b..4a7b20f0 100644 --- a/internal/tools/codebase/read_test.go +++ b/internal/tools/codebase/read_test.go @@ -32,8 +32,8 @@ func TestReadToolMetadata(t *testing.T) { if _, hasPath := props["path"]; !hasPath { t.Fatalf("Schema should have path property") } - if tool.MicroCompactPolicy() != tools.MicroCompactPolicyPreserveHistory { - t.Fatalf("MicroCompactPolicy() = %v, want PreserveHistory", tool.MicroCompactPolicy()) + if tool.MicroCompactPolicy() != tools.MicroCompactPolicyCompact { + t.Fatalf("MicroCompactPolicy() = %v, want Compact", tool.MicroCompactPolicy()) } } diff --git a/internal/tools/format.go b/internal/tools/format.go index 8cebb0b9..d9be2388 100644 --- a/internal/tools/format.go +++ b/internal/tools/format.go @@ -41,7 +41,9 @@ var projectedToolMetadataAllowlist = map[string]struct{}{ "returned_count": {}, "root": {}, "search_length": {}, + "source_path": {}, "status_code": {}, + "destination_path": {}, "tool_name": {}, "truncated": {}, "workdir": {}, diff --git a/internal/tools/format_test.go b/internal/tools/format_test.go index fb21c67e..20b84468 100644 --- a/internal/tools/format_test.go +++ b/internal/tools/format_test.go @@ -250,6 +250,27 @@ func TestSanitizeToolMetadata(t *testing.T) { } }, }, + { + name: "keeps copy and move path metadata but drops path arrays", + tool: "filesystem_move_file", + input: map[string]any{ + "source_path": "/repo/package.json", + "destination_path": "/repo/pkg.json", + "paths": []string{"/repo/package.json", "/repo/pkg.json"}, + }, + assert: func(t *testing.T, got map[string]string) { + t.Helper() + if got["source_path"] != "/repo/package.json" { + t.Fatalf("expected source_path to be preserved, got %#v", got) + } + if got["destination_path"] != "/repo/pkg.json" { + t.Fatalf("expected destination_path to be preserved, got %#v", got) + } + if got["paths"] != "" { + t.Fatalf("expected array metadata to be dropped, got %#v", got) + } + }, + }, } for _, tt := range tests { diff --git a/internal/tools/micro_compact_summarizer_test.go b/internal/tools/micro_compact_summarizer_test.go index d01901e6..bf3cee47 100644 --- a/internal/tools/micro_compact_summarizer_test.go +++ b/internal/tools/micro_compact_summarizer_test.go @@ -276,7 +276,7 @@ func TestRegisterBuiltinSummarizers(t *testing.T) { RegisterBuiltinSummarizers(registry) toolNames := []string{ - ToolNameBash, ToolNameFilesystemReadFile, ToolNameFilesystemWriteFile, + ToolNameBash, ToolNameFilesystemReadFile, ToolNameCodebaseRead, ToolNameFilesystemWriteFile, ToolNameFilesystemEdit, ToolNameFilesystemGrep, ToolNameFilesystemGlob, ToolNameWebFetch, } diff --git a/internal/tools/micro_compact_summarizers_builtin.go b/internal/tools/micro_compact_summarizers_builtin.go index d18481ae..de8c4886 100644 --- a/internal/tools/micro_compact_summarizers_builtin.go +++ b/internal/tools/micro_compact_summarizers_builtin.go @@ -14,6 +14,7 @@ type builtinSummarizerRegistration struct { var builtinSummarizers = []builtinSummarizerRegistration{ {toolName: ToolNameBash, summarizer: bashSummarizer}, {toolName: ToolNameFilesystemReadFile, summarizer: readFileSummarizer}, + {toolName: ToolNameCodebaseRead, summarizer: readFileSummarizer}, {toolName: ToolNameFilesystemWriteFile, summarizer: writeFileSummarizer}, {toolName: ToolNameFilesystemEdit, summarizer: editSummarizer}, {toolName: ToolNameFilesystemGrep, summarizer: grepSummarizer}, diff --git a/internal/tools/spawnsubagent/tool.go b/internal/tools/spawnsubagent/tool.go index 2394b218..95c07335 100644 --- a/internal/tools/spawnsubagent/tool.go +++ b/internal/tools/spawnsubagent/tool.go @@ -113,9 +113,9 @@ func (t *Tool) Schema() map[string]any { } } -// MicroCompactPolicy 声明 spawn_subagent 结果默认参与 micro compact。 +// MicroCompactPolicy 保留子代理结果,避免短期压缩时丢失分析链路与结论。 func (t *Tool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyCompact + return tools.MicroCompactPolicyPreserveHistory } // Execute 解析入参后执行 inline 模式。 diff --git a/internal/tools/spawnsubagent/tool_test.go b/internal/tools/spawnsubagent/tool_test.go index 3e691536..c06c1b1b 100644 --- a/internal/tools/spawnsubagent/tool_test.go +++ b/internal/tools/spawnsubagent/tool_test.go @@ -38,8 +38,8 @@ func TestToolMetadata(t *testing.T) { if strings.TrimSpace(tool.Description()) == "" { t.Fatalf("Description() should not be empty") } - if tool.MicroCompactPolicy() != tools.MicroCompactPolicyCompact { - t.Fatalf("MicroCompactPolicy() = %q, want compact", tool.MicroCompactPolicy()) + if tool.MicroCompactPolicy() != tools.MicroCompactPolicyPreserveHistory { + t.Fatalf("MicroCompactPolicy() = %q, want %q", tool.MicroCompactPolicy(), tools.MicroCompactPolicyPreserveHistory) } schema := tool.Schema() properties, ok := schema["properties"].(map[string]any) From ac11b421770558121e5386ebb56560ad54e4cf2f Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 10 May 2026 21:20:15 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(context):=20=E5=A4=84=E7=90=86=20micro?= =?UTF-8?q?=20compact=20=E8=AF=84=E5=AE=A1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/microcompact_test.go | 10 ++++++---- internal/tools/codebase/common_test.go | 22 +++++++++++++++++++-- internal/tools/codebase/read.go | 2 +- internal/tools/codebase/read_test.go | 4 ++-- internal/tools/filesystem/copy_file.go | 6 +++--- internal/tools/filesystem/copy_file_test.go | 11 +++++++++++ internal/tools/filesystem/move_file.go | 13 +++++++----- internal/tools/filesystem/move_file_test.go | 12 +++++++++-- internal/tools/format_test.go | 14 ++++++++----- 9 files changed, 70 insertions(+), 24 deletions(-) diff --git a/internal/context/microcompact_test.go b/internal/context/microcompact_test.go index 5b1bdf54..3d98c7b3 100644 --- a/internal/context/microcompact_test.go +++ b/internal/context/microcompact_test.go @@ -310,7 +310,7 @@ func TestMicroCompactMessagesPreservesSpawnSubAgentHistory(t *testing.T) { } } -func TestMicroCompactMessagesCompactsCodebaseReadToSummary(t *testing.T) { +func TestMicroCompactMessagesPreservesCodebaseReadHistory(t *testing.T) { t.Parallel() messages := []providertypes.Message{ @@ -339,9 +339,11 @@ func TestMicroCompactMessagesCompactsCodebaseReadToSummary(t *testing.T) { {Role: providertypes.RoleUser, Parts: []providertypes.ContentPart{providertypes.NewTextPart("latest explicit instruction")}}, } - got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{}, 2, nil, nil) - if !strings.Contains(renderDisplayParts(got[2].Parts), "[summary] codebase_read") { - t.Fatalf("expected codebase_read history to fall back to summary, got %q", renderDisplayParts(got[2].Parts)) + got := microCompactMessagesWithPolicies(messages, stubMicroCompactPolicySource{ + tools.ToolNameCodebaseRead: tools.MicroCompactPolicyPreserveHistory, + }, 2, nil, nil) + if renderDisplayParts(got[2].Parts) != "path: main.go\n\npackage main" { + t.Fatalf("expected codebase_read history to stay visible, got %q", renderDisplayParts(got[2].Parts)) } } diff --git a/internal/tools/codebase/common_test.go b/internal/tools/codebase/common_test.go index 15203a8d..56ee25a1 100644 --- a/internal/tools/codebase/common_test.go +++ b/internal/tools/codebase/common_test.go @@ -3,6 +3,8 @@ package codebase import ( "os" "path/filepath" + "runtime" + "strings" "testing" "neo-code/internal/tools" @@ -16,15 +18,19 @@ func TestCodebaseCommonHelpers(t *testing.T) { if err := os.Mkdir(child, 0o755); err != nil { t.Fatalf("Mkdir() error = %v", err) } + canonicalChild, err := filepath.EvalSymlinks(child) + if err != nil { + t.Fatalf("EvalSymlinks(child) error = %v", err) + } canonicalRoot, err := filepath.EvalSymlinks(root) if err != nil { t.Fatalf("EvalSymlinks(root) error = %v", err) } - if got, err := tools.ResolveEffectiveRoot(root, " "); err != nil || got != canonicalRoot { + if got, err := tools.ResolveEffectiveRoot(root, " "); err != nil || !samePathForTest(got, canonicalRoot) { t.Fatalf("effectiveRoot(default) = %q", got) } - if got, err := tools.ResolveEffectiveRoot(root, "subdir"); err != nil || got != child { + if got, err := tools.ResolveEffectiveRoot(root, "subdir"); err != nil || !samePathForTest(got, canonicalChild) { t.Fatalf("effectiveRoot(custom) = %q", got) } if got := itoa(0); got != "0" { @@ -43,3 +49,15 @@ func TestCodebaseCommonHelpers(t *testing.T) { t.Fatal("ResolveEffectiveRoot should reject escaping workdir") } } + +func samePathForTest(left string, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if left == right { + return true + } + if runtime.GOOS != "windows" { + return false + } + return strings.EqualFold(left, right) +} diff --git a/internal/tools/codebase/read.go b/internal/tools/codebase/read.go index 0a6db819..f76476e1 100644 --- a/internal/tools/codebase/read.go +++ b/internal/tools/codebase/read.go @@ -50,7 +50,7 @@ func (t *ReadTool) Schema() map[string]any { } func (t *ReadTool) MicroCompactPolicy() tools.MicroCompactPolicy { - return tools.MicroCompactPolicyCompact + return tools.MicroCompactPolicyPreserveHistory } func (t *ReadTool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { diff --git a/internal/tools/codebase/read_test.go b/internal/tools/codebase/read_test.go index 4a7b20f0..ba29f03b 100644 --- a/internal/tools/codebase/read_test.go +++ b/internal/tools/codebase/read_test.go @@ -32,8 +32,8 @@ func TestReadToolMetadata(t *testing.T) { if _, hasPath := props["path"]; !hasPath { t.Fatalf("Schema should have path property") } - if tool.MicroCompactPolicy() != tools.MicroCompactPolicyCompact { - t.Fatalf("MicroCompactPolicy() = %v, want Compact", tool.MicroCompactPolicy()) + if tool.MicroCompactPolicy() != tools.MicroCompactPolicyPreserveHistory { + t.Fatalf("MicroCompactPolicy() = %v, want PreserveHistory", tool.MicroCompactPolicy()) } } diff --git a/internal/tools/filesystem/copy_file.go b/internal/tools/filesystem/copy_file.go index 5ba62aea..9d2b8c52 100644 --- a/internal/tools/filesystem/copy_file.go +++ b/internal/tools/filesystem/copy_file.go @@ -118,9 +118,9 @@ func (t *CopyFileTool) Execute(ctx context.Context, input tools.ToolCallInput) ( Name: t.Name(), Content: "ok", Metadata: map[string]any{ - "source_path": src, - "destination_path": dst, - "paths": []string{dst}, + "source_path": normalizeSlashPath(toRelativePath(base, src)), + "destination_path": normalizeSlashPath(toRelativePath(base, dst)), + "paths": []string{normalizeSlashPath(toRelativePath(base, dst))}, "bytes": srcInfo.Size(), "overwrite": args.Overwrite, }, diff --git a/internal/tools/filesystem/copy_file_test.go b/internal/tools/filesystem/copy_file_test.go index bcada3b7..20abe267 100644 --- a/internal/tools/filesystem/copy_file_test.go +++ b/internal/tools/filesystem/copy_file_test.go @@ -53,6 +53,17 @@ func TestCopyFileTool_DuplicatesContent(t *testing.T) { if !ok || len(paths) != 1 { t.Fatalf("paths metadata = %#v want 1-item slice", result.Metadata["paths"]) } + if got, _ := result.Metadata["source_path"].(string); got != "a.go" { + t.Fatalf("source_path metadata = %q want a.go", got) + } + if got, _ := result.Metadata["destination_path"].(string); got != "nested/b.go" { + t.Fatalf("destination_path metadata = %q want nested/b.go", got) + } + for _, value := range []string{result.Metadata["source_path"].(string), result.Metadata["destination_path"].(string), paths[0]} { + if filepath.IsAbs(value) || strings.Contains(strings.ToLower(value), strings.ToLower(workspace)) { + t.Fatalf("expected metadata path to stay workspace-relative, got %q", value) + } + } } func TestCopyFileTool_RefusesOverwriteByDefault(t *testing.T) { diff --git a/internal/tools/filesystem/move_file.go b/internal/tools/filesystem/move_file.go index 6ed7121a..8340d415 100644 --- a/internal/tools/filesystem/move_file.go +++ b/internal/tools/filesystem/move_file.go @@ -127,11 +127,14 @@ func (t *MoveFileTool) Execute(ctx context.Context, input tools.ToolCallInput) ( Name: t.Name(), Content: "ok", Metadata: map[string]any{ - "source_path": src, - "destination_path": dst, - "paths": []string{src, dst}, - "bytes": srcInfo.Size(), - "overwrite": args.Overwrite, + "source_path": normalizeSlashPath(toRelativePath(base, src)), + "destination_path": normalizeSlashPath(toRelativePath(base, dst)), + "paths": []string{ + normalizeSlashPath(toRelativePath(base, src)), + normalizeSlashPath(toRelativePath(base, dst)), + }, + "bytes": srcInfo.Size(), + "overwrite": args.Overwrite, }, Facts: tools.ToolExecutionFacts{WorkspaceWrite: true}, }, nil diff --git a/internal/tools/filesystem/move_file_test.go b/internal/tools/filesystem/move_file_test.go index b76e8cc8..88965e1d 100644 --- a/internal/tools/filesystem/move_file_test.go +++ b/internal/tools/filesystem/move_file_test.go @@ -47,13 +47,21 @@ func TestMoveFileTool_RenamesWithinWorkspace(t *testing.T) { } else if string(data) != "hello" { t.Fatalf("dst content = %q want hello", string(data)) } - if got, ok := result.Metadata["destination_path"].(string); !ok || !strings.EqualFold(got, dst) { - t.Fatalf("destination_path metadata = %v want %v", got, dst) + if got, ok := result.Metadata["source_path"].(string); !ok || got != "old.go" { + t.Fatalf("source_path metadata = %v want old.go", got) + } + if got, ok := result.Metadata["destination_path"].(string); !ok || got != "renamed.go" { + t.Fatalf("destination_path metadata = %v want renamed.go", got) } paths, ok := result.Metadata["paths"].([]string) if !ok || len(paths) != 2 { t.Fatalf("paths metadata = %#v, want 2-item slice", result.Metadata["paths"]) } + for _, value := range []string{result.Metadata["source_path"].(string), result.Metadata["destination_path"].(string), paths[0], paths[1]} { + if filepath.IsAbs(value) || strings.Contains(strings.ToLower(value), strings.ToLower(workspace)) { + t.Fatalf("expected metadata path to stay workspace-relative, got %q", value) + } + } } func TestMoveFileTool_RejectsExistingDestinationWithoutOverwrite(t *testing.T) { diff --git a/internal/tools/format_test.go b/internal/tools/format_test.go index 20b84468..d5433ae8 100644 --- a/internal/tools/format_test.go +++ b/internal/tools/format_test.go @@ -2,6 +2,7 @@ package tools import ( "errors" + "path/filepath" "strings" "testing" @@ -251,21 +252,24 @@ func TestSanitizeToolMetadata(t *testing.T) { }, }, { - name: "keeps copy and move path metadata but drops path arrays", + name: "keeps relative copy and move path metadata but drops path arrays", tool: "filesystem_move_file", input: map[string]any{ - "source_path": "/repo/package.json", - "destination_path": "/repo/pkg.json", + "source_path": "package.json", + "destination_path": "pkg.json", "paths": []string{"/repo/package.json", "/repo/pkg.json"}, }, assert: func(t *testing.T, got map[string]string) { t.Helper() - if got["source_path"] != "/repo/package.json" { + if got["source_path"] != "package.json" { t.Fatalf("expected source_path to be preserved, got %#v", got) } - if got["destination_path"] != "/repo/pkg.json" { + if got["destination_path"] != "pkg.json" { t.Fatalf("expected destination_path to be preserved, got %#v", got) } + if filepath.IsAbs(got["source_path"]) || filepath.IsAbs(got["destination_path"]) { + t.Fatalf("expected projected copy/move paths to be relative, got %#v", got) + } if got["paths"] != "" { t.Fatalf("expected array metadata to be dropped, got %#v", got) }