main: fix write guard for new files, rangesCover off-by-one, drop 500-line cap, add /agents
- file_write: allow creating files that don't exist on disk without a prior read; the guard only applies to existing files - rangesCover: initialize cur = ws-1 so coverage check starts at the target range, not line 0 (range writes to e.g. lines 50-100 were incorrectly blocked even when exactly that range had been read) - remove fileReadMaxLines (500-line pagination cap); range tracking makes the truncation unnecessary - add /agents command: lists .json configs in agentsDir, marks active agent with * - add main_test.go: table-driven tests for rangesOverlap and rangesCover Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f6f3f60428
commit
864d13381a
77
main.go
77
main.go
|
|
@ -395,26 +395,30 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv {
|
|||
if a.Path != "" {
|
||||
st := fileRanges[a.Path]
|
||||
if st == nil {
|
||||
return "", fmt.Errorf("file_write: %s has not been read this session; read it first to avoid overwriting unknown changes", a.Path)
|
||||
}
|
||||
ws, we := a.StartLine, a.EndLine
|
||||
if ws == 0 && we == 0 {
|
||||
// Whole-file write: verify full coverage.
|
||||
totalLines := st.totalLines
|
||||
if totalLines == 0 {
|
||||
data, err := os.ReadFile(a.Path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file_write: cannot verify read coverage: %w", err)
|
||||
}
|
||||
totalLines = len(strings.Split(string(data), "\n"))
|
||||
// New files (not yet on disk) need no prior read.
|
||||
if _, statErr := os.Stat(a.Path); !os.IsNotExist(statErr) {
|
||||
return "", fmt.Errorf("file_write: %s has not been read this session; read it first to avoid overwriting unknown changes", a.Path)
|
||||
}
|
||||
ws, we = 1, totalLines
|
||||
} else {
|
||||
ws, we := a.StartLine, a.EndLine
|
||||
if ws == 0 && we == 0 {
|
||||
// Whole-file write: verify full coverage.
|
||||
totalLines := st.totalLines
|
||||
if totalLines == 0 {
|
||||
data, err := os.ReadFile(a.Path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("file_write: cannot verify read coverage: %w", err)
|
||||
}
|
||||
totalLines = len(strings.Split(string(data), "\n"))
|
||||
}
|
||||
ws, we = 1, totalLines
|
||||
}
|
||||
if !rangesCover(st.ranges, ws, we) {
|
||||
return "", fmt.Errorf("file_write: lines %d-%d of %s have not been read this session; read them first to avoid overwriting unknown changes", ws, we, a.Path)
|
||||
}
|
||||
// Invalidate: file content has changed, any cached ranges are stale.
|
||||
delete(fileRanges, a.Path)
|
||||
}
|
||||
if !rangesCover(st.ranges, ws, we) {
|
||||
return "", fmt.Errorf("file_write: lines %d-%d of %s have not been read this session; read them first to avoid overwriting unknown changes", ws, we, a.Path)
|
||||
}
|
||||
// Invalidate: file content has changed, any cached ranges are stale.
|
||||
delete(fileRanges, a.Path)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -959,6 +963,30 @@ func (m *model) handleCommand(input string) bool {
|
|||
m.display = append(m.display, fmt.Sprintf("Switched model to: %s", args[0]))
|
||||
return true
|
||||
|
||||
case "/agents":
|
||||
entries, err := os.ReadDir(m.agentsDir)
|
||||
if err != nil {
|
||||
m.display = append(m.display, fmt.Sprintf("agents: %v", err))
|
||||
return true
|
||||
}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(e.Name(), ".json")
|
||||
marker := " "
|
||||
if name == m.agentName {
|
||||
marker = "* "
|
||||
}
|
||||
m.display = append(m.display, marker+name)
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
m.display = append(m.display, "no agents found in "+m.agentsDir)
|
||||
}
|
||||
return true
|
||||
|
||||
case "/agent":
|
||||
if len(args) == 0 {
|
||||
m.display = append(m.display, fmt.Sprintf("active agent: %s", m.agentName))
|
||||
|
|
@ -1047,6 +1075,7 @@ func (m *model) handleCommand(input string) bool {
|
|||
|
||||
case "/help":
|
||||
m.display = append(m.display, "Available commands:")
|
||||
m.display = append(m.display, " /agents - List available agent configs")
|
||||
m.display = append(m.display, " /agent [name] - Show or switch active agent")
|
||||
m.display = append(m.display, " /backend <type> - Switch backend (ollama, openai)")
|
||||
m.display = append(m.display, " /model <name> - Switch model")
|
||||
|
|
@ -1346,8 +1375,6 @@ func dispatchExecutePipe(ctx context.Context, e *execpkg.Executor, confirm agent
|
|||
return e.Execute(ctx, code, "bash", timeout, sandbox, true)
|
||||
}
|
||||
|
||||
const fileReadMaxLines = 500
|
||||
|
||||
// lineRange is an inclusive [start, end] line range (1-based).
|
||||
type lineRange struct{ start, end int }
|
||||
|
||||
|
|
@ -1375,7 +1402,7 @@ func rangesCover(ranges []lineRange, ws, we int) bool {
|
|||
sorted := make([]lineRange, len(ranges))
|
||||
copy(sorted, ranges)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].start < sorted[j].start })
|
||||
cur := 0
|
||||
cur := ws - 1 // coverage must start at ws; anything before is irrelevant
|
||||
for _, r := range sorted {
|
||||
if r.start > cur+1 {
|
||||
break
|
||||
|
|
@ -1425,19 +1452,11 @@ func dispatchFileRead(confirm agent.ConfirmFn, args json.RawMessage) (fileReadRe
|
|||
if start > end {
|
||||
return fileReadResult{}, fmt.Errorf("file_read: start_line %d > end_line %d", start, end)
|
||||
}
|
||||
truncated := false
|
||||
if end-start+1 > fileReadMaxLines {
|
||||
end = start + fileReadMaxLines - 1
|
||||
truncated = true
|
||||
}
|
||||
var out strings.Builder
|
||||
for i, line := range lines[start-1 : end] {
|
||||
fmt.Fprintf(&out, "%d\t%s\n", start+i, line)
|
||||
}
|
||||
content := strings.TrimRight(out.String(), "\n")
|
||||
if truncated {
|
||||
content += fmt.Sprintf("\n[truncated: showing lines %d-%d of %d; use start_line/end_line or grep -n to narrow range]", start, end, totalLines)
|
||||
}
|
||||
return fileReadResult{content: content, start: start, end: end, totalLines: totalLines}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRangesOverlap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ranges []lineRange
|
||||
ws, we int
|
||||
want bool
|
||||
}{
|
||||
{"empty ranges", nil, 1, 10, false},
|
||||
{"exact match", []lineRange{{1, 10}}, 1, 10, true},
|
||||
{"query contained in range", []lineRange{{1, 100}}, 20, 30, true},
|
||||
{"range contained in query", []lineRange{{20, 30}}, 1, 100, true},
|
||||
{"adjacent before, no overlap", []lineRange{{1, 10}}, 11, 20, false},
|
||||
{"adjacent after, no overlap", []lineRange{{11, 20}}, 1, 10, false},
|
||||
{"overlap at start", []lineRange{{5, 15}}, 1, 10, true},
|
||||
{"overlap at end", []lineRange{{5, 15}}, 10, 20, true},
|
||||
{"single line match", []lineRange{{5, 5}}, 5, 5, true},
|
||||
{"single line no match", []lineRange{{5, 5}}, 6, 6, false},
|
||||
{"multiple ranges, one overlaps", []lineRange{{1, 5}, {20, 30}}, 25, 35, true},
|
||||
{"multiple ranges, none overlap", []lineRange{{1, 5}, {20, 30}}, 10, 15, false},
|
||||
{"boundary: last line of range", []lineRange{{1, 10}}, 10, 10, true},
|
||||
{"boundary: first line of range", []lineRange{{10, 20}}, 10, 10, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := rangesOverlap(tt.ranges, tt.ws, tt.we)
|
||||
if got != tt.want {
|
||||
t.Errorf("rangesOverlap(%v, %d, %d) = %v; want %v",
|
||||
tt.ranges, tt.ws, tt.we, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangesCover(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ranges []lineRange
|
||||
ws, we int
|
||||
want bool
|
||||
}{
|
||||
{"empty ranges", nil, 1, 10, false},
|
||||
{"exact match", []lineRange{{1, 10}}, 1, 10, true},
|
||||
{"superset covers", []lineRange{{1, 100}}, 20, 30, true},
|
||||
{"single line covered", []lineRange{{5, 5}}, 5, 5, true},
|
||||
{"single line not covered", []lineRange{{5, 5}}, 6, 6, false},
|
||||
{"contiguous ranges cover", []lineRange{{1, 5}, {6, 10}}, 1, 10, true},
|
||||
{"overlapping ranges cover", []lineRange{{1, 7}, {5, 10}}, 1, 10, true},
|
||||
{"gap leaves target uncovered", []lineRange{{1, 5}, {7, 10}}, 1, 10, false},
|
||||
{"partial coverage from start", []lineRange{{1, 5}}, 1, 10, false},
|
||||
{"partial coverage at end only", []lineRange{{5, 10}}, 1, 10, false},
|
||||
{"target starts before coverage", []lineRange{{5, 10}}, 3, 10, false},
|
||||
{"target starts mid-coverage", []lineRange{{1, 10}}, 5, 10, true},
|
||||
{"non-contiguous covers when sorted", []lineRange{{6, 10}, {1, 5}}, 1, 10, true},
|
||||
{"three ranges no gap", []lineRange{{1, 3}, {4, 6}, {7, 10}}, 1, 10, true},
|
||||
{"range write: only that range read", []lineRange{{50, 100}}, 50, 100, true},
|
||||
{"range write: superset read", []lineRange{{1, 200}}, 50, 100, true},
|
||||
{"range write: not read", []lineRange{{1, 49}}, 50, 100, false},
|
||||
{"range write: partial lower", []lineRange{{50, 75}}, 50, 100, false},
|
||||
{"range write: partial upper", []lineRange{{75, 100}}, 50, 100, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := rangesCover(tt.ranges, tt.ws, tt.we)
|
||||
if got != tt.want {
|
||||
t.Errorf("rangesCover(%v, %d, %d) = %v; want %v",
|
||||
tt.ranges, tt.ws, tt.we, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue