From 864d13381a22297fc23a979cd0d471a352cbdbaa Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Tue, 7 Apr 2026 10:25:02 +0200 Subject: [PATCH] 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 --- main.go | 77 ++++++++++++++++++++++++++++++++-------------------- main_test.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 29 deletions(-) create mode 100644 main_test.go diff --git a/main.go b/main.go index d8c949e..e4f5ffd 100644 --- a/main.go +++ b/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 - Switch backend (ollama, openai)") m.display = append(m.display, " /model - 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 } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..339937b --- /dev/null +++ b/main_test.go @@ -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) + } + }) + } +}