diff --git a/cmd/sin-code/chat_tools.go b/cmd/sin-code/chat_tools.go index e0186d9a..8e8dd768 100644 --- a/cmd/sin-code/chat_tools.go +++ b/cmd/sin-code/chat_tools.go @@ -32,11 +32,13 @@ const ( // and network calls. Production defaults point to the real implementations. var ( toolReadFn = toolRead - toolWriteFn = toolWrite - toolEditFn = toolEdit - toolBashFn = toolBash - toolSearchFn = toolSearch - toolBootstrapSkillFn = toolBootstrapSkill + toolWriteFn = toolWrite + toolEditFn = toolEdit + toolApplyDiffFn = toolApplyDiff + toolGenerateDiffFn = toolGenerateDiff + toolBashFn = toolBash + toolSearchFn = toolSearch + toolBootstrapSkillFn = toolBootstrapSkill toolSearchWalkErrFn = func(_ string, err error) error { return nil } metaBootstrapSkillFn = meta.BootstrapSkill ) @@ -57,7 +59,11 @@ func builtinSpecs() []agentloopToolSpecAlias { InputSchema: obj(map[string]any{"path": str("file path"), "content": str("full file content")}, "path", "content")}, {Name: "sin_edit", Description: "Replace the first exact occurrence of old with new in a file.", InputSchema: obj(map[string]any{"path": str("file path"), "old": str("exact text to replace"), "new": str("replacement text")}, "path", "old", "new")}, - {Name: "sin_bash", Description: "Run a shell command in the workspace (120s timeout).", + {Name: "sin_apply_diff", Description: "Apply a unified diff to a file. Validates each hunk before applying and reports applied/rejected hunks. (issue #365)", + InputSchema: obj(map[string]any{"path": str("file path"), "diff": str("unified diff string")}, "path", "diff")}, + {Name: "sin_generate_diff", Description: "Generate a unified diff from old and new content. (issue #365)", + InputSchema: obj(map[string]any{"old_content": str("original content"), "new_content": str("updated content")}, "old_content", "new_content")}, + {Name: "sin_bash", Description: "Run a shell command in the workspace (120s timeout).", InputSchema: obj(map[string]any{"command": str("shell command")}, "command")}, {Name: "sin_search", Description: "Search files for a substring; returns file:line matches.", InputSchema: obj(map[string]any{"pattern": str("substring to search"), "dir": str("directory (default .)")}, "pattern")}, @@ -78,6 +84,10 @@ func builtinTool(ctx context.Context, workspace, name string, args map[string]an return toolWriteFn(argStr(args, "path"), argStr(args, "content")) case "sin_edit": return toolEditFn(argStr(args, "path"), argStr(args, "old"), argStr(args, "new")) + case "sin_apply_diff": + return toolApplyDiffFn(argStr(args, "path"), argStr(args, "diff")) + case "sin_generate_diff": + return toolGenerateDiffFn(argStr(args, "old_content"), argStr(args, "new_content")) case "sin_bash": return toolBashFn(ctx, argStr(args, "command")) case "sin_search": @@ -192,6 +202,31 @@ func toolEdit(path, old, new string) (string, error) { return result, nil } +func toolApplyDiff(path, diff string) (string, error) { + if path == "" || diff == "" { + return "", fmt.Errorf("sin_apply_diff: path and diff required") + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + applier := agentloop.NewDiffApplier() + updated, err := applier.Apply(string(data), diff) + if err != nil { + return "", fmt.Errorf("sin_apply_diff: %w", err) + } + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + return "", err + } + result := "applied diff to " + path + result += maybeGenerateTest(path) + return result, nil +} + +func toolGenerateDiff(oldContent, newContent string) (string, error) { + return agentloop.GenerateDiff(oldContent, newContent), nil +} + // sandboxConfig controls OS-level isolation for sin_bash (issue #367). var sandboxConfig struct { enabled bool diff --git a/cmd/sin-code/internal/agentloop/apply_diff.go b/cmd/sin-code/internal/agentloop/apply_diff.go index 8b08311b..886bf3ad 100644 --- a/cmd/sin-code/internal/agentloop/apply_diff.go +++ b/cmd/sin-code/internal/agentloop/apply_diff.go @@ -325,3 +325,63 @@ func splitLines(text string) []string { } return lines } + +// GenerateDiff produces a unified diff string from old and new content. +// It uses a simple line-based algorithm that groups consecutive changes into +// one hunk. The output is valid for ApplyDiff and human-readable. +func GenerateDiff(oldContent, newContent string) string { + oldLines := splitLines(oldContent) + newLines := splitLines(newContent) + + // Find common prefix. + prefix := 0 + for prefix < len(oldLines) && prefix < len(newLines) && oldLines[prefix] == newLines[prefix] { + prefix++ + } + + // Find common suffix after the prefix. + suffix := 0 + for suffix < len(oldLines)-prefix && suffix < len(newLines)-prefix && + oldLines[len(oldLines)-1-suffix] == newLines[len(newLines)-1-suffix] { + suffix++ + } + + oldEnd := len(oldLines) - suffix + newEnd := len(newLines) - suffix + + ctxBefore := 3 + if prefix < ctxBefore { + ctxBefore = prefix + } + ctxAfter := 3 + if oldEnd+ctxAfter > len(oldLines) { + ctxAfter = len(oldLines) - oldEnd + if ctxAfter < 0 { + ctxAfter = 0 + } + } + + oldStart := prefix - ctxBefore + 1 + newStart := prefix - ctxBefore + 1 + oldLinesCount := ctxBefore + (oldEnd - prefix) + ctxAfter + newLinesCount := ctxBefore + (newEnd - prefix) + ctxAfter + + var b strings.Builder + fmt.Fprintf(&b, "--- a/file\n+++ b/file\n") + fmt.Fprintf(&b, "@@ -%d,%d +%d,%d @@\n", oldStart, oldLinesCount, newStart, newLinesCount) + + for i := prefix - ctxBefore; i < prefix; i++ { + fmt.Fprintf(&b, " %s\n", oldLines[i]) + } + for i := prefix; i < oldEnd; i++ { + fmt.Fprintf(&b, "-%s\n", oldLines[i]) + } + for i := prefix; i < newEnd; i++ { + fmt.Fprintf(&b, "+%s\n", newLines[i]) + } + for i := oldEnd; i < oldEnd+ctxAfter && i < len(oldLines); i++ { + fmt.Fprintf(&b, " %s\n", oldLines[i]) + } + + return b.String() +} diff --git a/cmd/sin-code/internal/agentloop/apply_diff_test.go b/cmd/sin-code/internal/agentloop/apply_diff_test.go index 67c1da5b..ebc20b6c 100644 --- a/cmd/sin-code/internal/agentloop/apply_diff_test.go +++ b/cmd/sin-code/internal/agentloop/apply_diff_test.go @@ -198,6 +198,41 @@ func TestApplyDiff_FileHeadersSkipped(t *testing.T) { } } +func TestGenerateDiff_RoundTrip(t *testing.T) { + oldContent := "alpha\nbeta\ngamma\n" + newContent := "alpha\nBETA\ngamma\n" + diff := GenerateDiff(oldContent, newContent) + hunks, err := ParseUnifiedDiff(diff) + if err != nil { + t.Fatalf("ParseUnifiedDiff: %v", err) + } + result, err := ApplyDiff(oldContent, hunks) + if err != nil { + t.Fatalf("ApplyDiff: %v", err) + } + if result != newContent { + t.Errorf("round-trip = %q, want %q", result, newContent) + } +} + +func TestGenerateDiff_Insertion(t *testing.T) { + oldContent := "a\nc\n" + newContent := "a\nb\nc\n" + diff := GenerateDiff(oldContent, newContent) + if !strings.Contains(diff, "+b") { + t.Errorf("diff missing inserted line: %s", diff) + } +} + +func TestGenerateDiff_Deletion(t *testing.T) { + oldContent := "a\nb\nc\n" + newContent := "a\nc\n" + diff := GenerateDiff(oldContent, newContent) + if !strings.Contains(diff, "-b") { + t.Errorf("diff missing removed line: %s", diff) + } +} + // isBinary returns true if the content contains NUL bytes, which is the // standard heuristic for binary file detection. func isBinary(content string) bool { diff --git a/cmd/sin-code/internal/permission_defaults.go b/cmd/sin-code/internal/permission_defaults.go index 525e2c37..97b2181d 100644 --- a/cmd/sin-code/internal/permission_defaults.go +++ b/cmd/sin-code/internal/permission_defaults.go @@ -14,6 +14,8 @@ func DefaultPermissionRules() []permission.Rule { {Tool: "sin_read", Policy: "allow"}, {Tool: "sin_write", Policy: "allow"}, {Tool: "sin_edit", Policy: "allow"}, + {Tool: "sin_apply_diff", Policy: "allow"}, // v3.23.0: unified diff editor (issue #365) + {Tool: "sin_generate_diff", Policy: "allow"}, // v3.23.0: diff generator (issue #365) {Tool: "sin_test", Policy: "allow"}, {Tool: "sin_quality_gate", Policy: "allow"}, // v3.21.0: Test-First Verify-Loop (RFC-test-automation) {Tool: "sin_mutation", Policy: "allow"},