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:
Levi Neely 2026-04-07 10:25:02 +02:00
parent f6f3f60428
commit 864d13381a
2 changed files with 124 additions and 29 deletions

77
main.go
View File

@ -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
}

76
main_test.go Normal file
View File

@ -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)
}
})
}
}