Skip to content

Conversation

Copilot
Copy link
Contributor

@Copilot Copilot AI commented Oct 9, 2025

  • Explore repository structure and understand existing tools pattern
  • Create tools_readdir.go file with read_dir tool implementation
  • Add GetReadDirToolDefinition() function following the pattern of read_text_file
  • Register the new tool in tools.go GenerateTabStateAndTools function
  • Create comprehensive tests in tools_readdir_test.go
  • Test the implementation manually with various scenarios
  • Run Go tests to ensure no regressions
  • Run security check with CodeQL - No vulnerabilities found
  • Revert unintended changes to tsunami demo go.mod and go.sum files
  • Fix sorting to happen before truncation and preserve real total count

Summary

Successfully implemented a new read_dir AI tool that reads and lists directory contents, following the same pattern as the existing read_text_file tool.

Key Features:

  • Supports path expansion (including ~)
  • Sorts directories first, then files (sorting happens BEFORE truncation)
  • Truncates output to prevent overwhelming responses (default 1000 entries)
  • Preserves the real total count even when truncated
  • Requires user approval for security
  • Provides detailed file/directory information (name, type, size, permissions, modification time)
  • Returns both structured data and formatted listing

Files Changed:

  • pkg/aiusechat/tools_readdir.go - Main implementation (189 lines)
  • pkg/aiusechat/tools_readdir_test.go - Comprehensive tests (211 lines)
  • pkg/aiusechat/tools.go - Tool registration (1 line)

Testing:

  • ✅ All 6 unit tests passing (including new test for sort-before-truncate)
  • ✅ Manual testing with real directories successful
  • ✅ CodeQL security scan passed with no vulnerabilities
  • ✅ Go build and vet successful
Original prompt

Right now we have AI tools in go at pkg/aichat ... see tools.go, tools_readfile.go. i'd like to add a new tool to read directories in the style of readfile.


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

@Copilot Copilot AI assigned Copilot and sawka Oct 9, 2025
@CLAassistant
Copy link

CLAassistant commented Oct 9, 2025

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ sawka
❌ Copilot
You have signed the CLA already but the status is still pending? Let us recheck it.

Copilot AI and others added 2 commits October 9, 2025 23:47
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
@Copilot Copilot AI changed the title [WIP] Add new tool to read directories in aichat Add read_dir AI tool for reading directory contents Oct 9, 2025
Copilot finished work on behalf of sawka October 9, 2025 23:52
@Copilot Copilot AI requested a review from sawka October 9, 2025 23:52
@sawka
Copy link
Member

sawka commented Oct 9, 2025

@copilot can you undo the changes to the go.mod and go.sum files in the tsunami directories. those shouldn't be changed by this PR (out of scope)

Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Oct 9, 2025

@copilot can you undo the changes to the go.mod and go.sum files in the tsunami directories. those shouldn't be changed by this PR (out of scope)

Reverted the tsunami demo go.mod and go.sum changes in commit 7d1b5e3.

Copilot finished work on behalf of sawka October 9, 2025 23:58
@sawka sawka marked this pull request as ready for review October 10, 2025 00:06
Copy link
Contributor

coderabbitai bot commented Oct 10, 2025

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

Adds a new read_dir tool to aiusechat. Implements read_dir logic to parse inputs (path required, optional max_entries with default), expand ~, validate directory, read entries, sort (dirs first, then files; alphabetical within groups), truncate per max_entries, and build structured results plus a human-readable listing. Returns metadata including path, absolute_path, entry_count, total_entries, entries, listing, truncated flag, and parent_dir when applicable. Exposes GetReadDirToolDefinition and a ReadDirDefaultMaxEntries constant. Integrates the tool into the tools list in tools.go when widgetAccess is true, immediately after the read_text_file tool. Includes comprehensive unit tests for success, errors, truncation, and definition metadata.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The title clearly and concisely summarizes the addition of a new read_dir AI tool for directory contents and directly reflects the main change in the PR.
Description Check ✅ Passed The description directly outlines the implementation steps, features, files changed, and testing performed for the new read_dir tool, clearly relating to and summarizing the changeset.

Comment @coderabbitai help to get the list of available commands and usage tips.

@sawka
Copy link
Member

sawka commented Oct 10, 2025

@coderabbitai review

Copy link
Contributor

coderabbitai bot commented Oct 10, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
pkg/aiusechat/tools_readdir_test.go (1)

106-109: Avoid slicing the error string to check its prefix.

Taking err.Error()[:len(expectedErrSubstr)] will panic if the message ever shortens. A simple strings.Contains (or strings.HasPrefix) keeps the assertion stable without depending on length.

-	expectedErrSubstr := "path is not a directory"
-	if err.Error()[:len(expectedErrSubstr)] != expectedErrSubstr {
-		t.Errorf("Expected error containing %q, got %q", expectedErrSubstr, err.Error())
-	}
+	if !strings.Contains(err.Error(), "path is not a directory") {
+		t.Errorf("Expected error containing %q, got %q", "path is not a directory", err.Error())
+	}

Add strings to the import list above.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 47bdc38 and b723292.

📒 Files selected for processing (3)
  • pkg/aiusechat/tools.go (1 hunks)
  • pkg/aiusechat/tools_readdir.go (1 hunks)
  • pkg/aiusechat/tools_readdir_test.go (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Analyze (go)
  • GitHub Check: merge-gatekeeper
  • GitHub Check: Build for TestDriver.ai

Comment on lines 77 to 142
maxEntries := *params.MaxEntries
var truncated bool
if len(entries) > maxEntries {
entries = entries[:maxEntries]
truncated = true
}

// Sort entries: directories first, then files, alphabetically within each group
sort.Slice(entries, func(i, j int) bool {
if entries[i].IsDir() != entries[j].IsDir() {
return entries[i].IsDir()
}
return entries[i].Name() < entries[j].Name()
})

var entryList []map[string]any
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
// Skip entries we can't stat
continue
}

entryData := map[string]any{
"name": entry.Name(),
"is_dir": entry.IsDir(),
"mode": info.Mode().String(),
"modified": utilfn.FormatRelativeTime(info.ModTime()),
}

if !entry.IsDir() {
entryData["size"] = info.Size()
}

entryList = append(entryList, entryData)
}

// Create a formatted directory listing
var listing strings.Builder
for _, entry := range entryList {
name := entry["name"].(string)
isDir := entry["is_dir"].(bool)
mode := entry["mode"].(string)
modified := entry["modified"].(string)

if isDir {
listing.WriteString(fmt.Sprintf("[DIR] %-40s %s %s\n", name, mode, modified))
} else {
size := entry["size"].(int64)
listing.WriteString(fmt.Sprintf("[FILE] %-40s %10d %s %s\n", name, size, mode, modified))
}
}

result := map[string]any{
"path": params.Path,
"absolute_path": expandedPath,
"entry_count": len(entryList),
"total_entries": len(entries),
"entries": entryList,
"listing": strings.TrimSuffix(listing.String(), "\n"),
}

if truncated {
result["truncated"] = true
result["truncated_message"] = fmt.Sprintf("Directory listing truncated to %d entries (out of %d+ total). Increase max_entries to see more.", maxEntries, maxEntries)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sort before truncating and preserve the real totals.

We slice entries before sorting them, so when a directory alphabetically falls beyond the first max_entries results, it never makes the listing—even though the feature promises “directories first”. In addition, total_entries (and the truncation message) now report the truncated count, so callers can’t tell how much was omitted. Please sort the full set first, keep the original total, and only then apply truncation so the metadata stays accurate.

-	entries, err := os.ReadDir(expandedPath)
+	entries, err := os.ReadDir(expandedPath)
 	if err != nil {
 		return nil, fmt.Errorf("failed to read directory: %w", err)
 	}
 
-	maxEntries := *params.MaxEntries
-	var truncated bool
-	if len(entries) > maxEntries {
-		entries = entries[:maxEntries]
-		truncated = true
-	}
-
-	// Sort entries: directories first, then files, alphabetically within each group
-	sort.Slice(entries, func(i, j int) bool {
+	totalEntries := len(entries)
+	maxEntries := *params.MaxEntries
+
+	// Sort entries: directories first, then files, alphabetically within each group
+	sort.Slice(entries, func(i, j int) bool {
 		if entries[i].IsDir() != entries[j].IsDir() {
 			return entries[i].IsDir()
 		}
 		return entries[i].Name() < entries[j].Name()
 	})
+
+	var truncated bool
+	if totalEntries > maxEntries {
+		entries = entries[:maxEntries]
+		truncated = true
+	}
@@
 	result := map[string]any{
 		"path":          params.Path,
 		"absolute_path": expandedPath,
 		"entry_count":   len(entryList),
-		"total_entries": len(entries),
+		"total_entries": totalEntries,
 		"entries":       entryList,
 		"listing":       strings.TrimSuffix(listing.String(), "\n"),
 	}
 
 	if truncated {
 		result["truncated"] = true
-		result["truncated_message"] = fmt.Sprintf("Directory listing truncated to %d entries (out of %d+ total). Increase max_entries to see more.", maxEntries, maxEntries)
+		result["truncated_message"] = fmt.Sprintf("Directory listing truncated to %d entries out of %d total. Increase max_entries to see more.", len(entryList), totalEntries)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
maxEntries := *params.MaxEntries
var truncated bool
if len(entries) > maxEntries {
entries = entries[:maxEntries]
truncated = true
}
// Sort entries: directories first, then files, alphabetically within each group
sort.Slice(entries, func(i, j int) bool {
if entries[i].IsDir() != entries[j].IsDir() {
return entries[i].IsDir()
}
return entries[i].Name() < entries[j].Name()
})
var entryList []map[string]any
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
// Skip entries we can't stat
continue
}
entryData := map[string]any{
"name": entry.Name(),
"is_dir": entry.IsDir(),
"mode": info.Mode().String(),
"modified": utilfn.FormatRelativeTime(info.ModTime()),
}
if !entry.IsDir() {
entryData["size"] = info.Size()
}
entryList = append(entryList, entryData)
}
// Create a formatted directory listing
var listing strings.Builder
for _, entry := range entryList {
name := entry["name"].(string)
isDir := entry["is_dir"].(bool)
mode := entry["mode"].(string)
modified := entry["modified"].(string)
if isDir {
listing.WriteString(fmt.Sprintf("[DIR] %-40s %s %s\n", name, mode, modified))
} else {
size := entry["size"].(int64)
listing.WriteString(fmt.Sprintf("[FILE] %-40s %10d %s %s\n", name, size, mode, modified))
}
}
result := map[string]any{
"path": params.Path,
"absolute_path": expandedPath,
"entry_count": len(entryList),
"total_entries": len(entries),
"entries": entryList,
"listing": strings.TrimSuffix(listing.String(), "\n"),
}
if truncated {
result["truncated"] = true
result["truncated_message"] = fmt.Sprintf("Directory listing truncated to %d entries (out of %d+ total). Increase max_entries to see more.", maxEntries, maxEntries)
}
entries, err := os.ReadDir(expandedPath)
if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
totalEntries := len(entries)
maxEntries := *params.MaxEntries
// Sort entries: directories first, then files, alphabetically within each group
sort.Slice(entries, func(i, j int) bool {
if entries[i].IsDir() != entries[j].IsDir() {
return entries[i].IsDir()
}
return entries[i].Name() < entries[j].Name()
})
var truncated bool
if totalEntries > maxEntries {
entries = entries[:maxEntries]
truncated = true
}
var entryList []map[string]any
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
// Skip entries we can't stat
continue
}
entryData := map[string]any{
"name": entry.Name(),
"is_dir": entry.IsDir(),
"mode": info.Mode().String(),
"modified": utilfn.FormatRelativeTime(info.ModTime()),
}
if !entry.IsDir() {
entryData["size"] = info.Size()
}
entryList = append(entryList, entryData)
}
// Create a formatted directory listing
var listing strings.Builder
for _, entry := range entryList {
name := entry["name"].(string)
isDir := entry["is_dir"].(bool)
mode := entry["mode"].(string)
modified := entry["modified"].(string)
if isDir {
listing.WriteString(fmt.Sprintf("[DIR] %-40s %s %s\n", name, mode, modified))
} else {
size := entry["size"].(int64)
listing.WriteString(fmt.Sprintf("[FILE] %-40s %10d %s %s\n", name, size, mode, modified))
}
}
result := map[string]any{
"path": params.Path,
"absolute_path": expandedPath,
"entry_count": len(entryList),
"total_entries": totalEntries,
"entries": entryList,
"listing": strings.TrimSuffix(listing.String(), "\n"),
}
if truncated {
result["truncated"] = true
result["truncated_message"] = fmt.Sprintf(
"Directory listing truncated to %d entries out of %d total. Increase max_entries to see more.",
len(entryList), totalEntries,
)
}
🤖 Prompt for AI Agents
In pkg/aiusechat/tools_readdir.go around lines 77 to 142: the code currently
truncates entries before sorting and uses the truncated length as total_entries
and in the truncated message; instead, preserve the full entries slice for
sorting, record originalTotal := len(entries) before any truncation, sort the
full entries (directories first, then names), then apply truncation into a
separate slice when originalTotal > maxEntries and set truncated = true; set
"total_entries" to originalTotal (not len of truncated slice) and format the
truncated_message to show maxEntries and originalTotal so callers know how many
were omitted.

@sawka
Copy link
Member

sawka commented Oct 10, 2025

@copilot

Sort before truncating and preserve the real totals.

We slice entries before sorting them, so when a directory alphabetically falls beyond the first max_entries results, it never makes the listing—even though the feature promises “directories first”. In addition, total_entries (and the truncation message) now report the truncated count, so callers can’t tell how much was omitted. Please sort the full set first, keep the original total, and only then apply truncation so the metadata stays accurate.

Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Oct 10, 2025

@copilot

Sort before truncating and preserve the real totals.
...

Fixed in commit b723292. Now sorts the full entry list first (ensuring directories come first), preserves the original total count, then truncates. The total_entries field and truncation message now correctly report the real total instead of the truncated count.

Copilot finished work on behalf of sawka October 10, 2025 00:27
@sawka
Copy link
Member

sawka commented Oct 10, 2025

closing/reopening to try to re-trigger CLA

@sawka sawka closed this Oct 10, 2025
@sawka
Copy link
Member

sawka commented Oct 10, 2025

closing/reopening to try to re-trigger CLA

@sawka sawka reopened this Oct 10, 2025
@sawka sawka merged commit fa19d7c into main Oct 10, 2025
5 of 8 checks passed
@sawka sawka deleted the copilot/add-directory-read-tool branch October 10, 2025 04:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants