feat: extract project dir from file tool paths too, not just exec cd
Refactored extractProjectDir to accept any tool call. For read_file, edit_file etc., the file path is used to detect the project directory. Extracted shared logic into projectDirFromPath. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d6733de91e
commit
6297d3679e
2 changed files with 99 additions and 45 deletions
|
|
@ -956,37 +956,35 @@ var cdPrefixPattern = regexp.MustCompile(`^cd\s+(\S+)\s*&&\s*`)
|
||||||
// argument (e.g. "-A 20") are kept because removing them would lose context.
|
// argument (e.g. "-A 20") are kept because removing them would lose context.
|
||||||
var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`)
|
var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`)
|
||||||
|
|
||||||
// extractProjectDir extracts the project directory name from an exec command's
|
// projectDirFromPath extracts the project directory name from an absolute path
|
||||||
// "cd <path> && ..." prefix by stripping the workspace prefix and taking the
|
// by stripping the workspace prefix and returning the first meaningful component.
|
||||||
// first remaining path component.
|
// Generic directory names like "projects" or "repos" are skipped.
|
||||||
// Returns "" if no cd prefix or no deeper directory exists.
|
// Returns "" if the path doesn't extend beyond workspace.
|
||||||
func extractProjectDir(cmd, workspace string) string {
|
func projectDirFromPath(absPath, workspace string) string {
|
||||||
m := cdPrefixPattern.FindStringSubmatch(cmd)
|
absPath = strings.TrimRight(absPath, "/\\")
|
||||||
if len(m) < 2 {
|
if absPath == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
cdPath := strings.TrimRight(m[1], "/\\")
|
|
||||||
if workspace == "" {
|
if workspace == "" {
|
||||||
if idx := strings.LastIndex(cdPath, "/"); idx >= 0 {
|
if idx := strings.LastIndex(absPath, "/"); idx >= 0 {
|
||||||
return cdPath[idx+1:]
|
return absPath[idx+1:]
|
||||||
}
|
}
|
||||||
return cdPath
|
return absPath
|
||||||
}
|
}
|
||||||
ws := strings.TrimRight(workspace, "/\\")
|
ws := strings.TrimRight(workspace, "/\\")
|
||||||
rest := strings.TrimPrefix(cdPath, ws)
|
rest := strings.TrimPrefix(absPath, ws)
|
||||||
if rest == cdPath {
|
if rest == absPath {
|
||||||
// workspace not a prefix — fall back to last component
|
// workspace not a prefix — fall back to last component
|
||||||
if idx := strings.LastIndex(cdPath, "/"); idx >= 0 {
|
if idx := strings.LastIndex(absPath, "/"); idx >= 0 {
|
||||||
return cdPath[idx+1:]
|
return absPath[idx+1:]
|
||||||
}
|
}
|
||||||
return cdPath
|
return absPath
|
||||||
}
|
}
|
||||||
rest = strings.TrimLeft(rest, "/\\")
|
rest = strings.TrimLeft(rest, "/\\")
|
||||||
if rest == "" {
|
if rest == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// Take first path component (e.g. "projects/my-app" → "projects")
|
// Take first path component, skipping generic directory names
|
||||||
// But if it looks like a generic dir (projects, src, workspace), go deeper
|
|
||||||
parts := strings.SplitN(rest, "/", 3)
|
parts := strings.SplitN(rest, "/", 3)
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
first := strings.ToLower(parts[0])
|
first := strings.ToLower(parts[0])
|
||||||
|
|
@ -997,6 +995,33 @@ func extractProjectDir(cmd, workspace string) string {
|
||||||
return parts[0]
|
return parts[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractProjectDir extracts the project directory name from a tool call.
|
||||||
|
// For exec: parses the "cd <path> && ..." prefix.
|
||||||
|
// For file tools: uses the file path directly.
|
||||||
|
// Returns "" if no project directory can be determined.
|
||||||
|
func extractProjectDir(toolName string, args map[string]interface{}, workspace string) string {
|
||||||
|
switch toolName {
|
||||||
|
case "exec":
|
||||||
|
cmd, _ := args["command"].(string)
|
||||||
|
if cmd == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
m := cdPrefixPattern.FindStringSubmatch(cmd)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return projectDirFromPath(m[1], workspace)
|
||||||
|
|
||||||
|
case "read_file", "write_file", "edit_file", "append_file", "list_dir":
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return projectDirFromPath(path, workspace)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// buildArgsSnippet produces a human-friendly snippet for the tool log.
|
// buildArgsSnippet produces a human-friendly snippet for the tool log.
|
||||||
// For exec: extracts the command and strips the leading "cd <workspace> && ".
|
// For exec: extracts the command and strips the leading "cd <workspace> && ".
|
||||||
// For file tools: extracts the path and strips the workspace prefix.
|
// For file tools: extracts the path and strips the workspace prefix.
|
||||||
|
|
@ -1534,11 +1559,9 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace),
|
ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace),
|
||||||
Result: "\u23F3",
|
Result: "\u23F3",
|
||||||
})
|
})
|
||||||
// Detect project directory from exec cd prefix (once)
|
// Detect project directory from tool call args (once)
|
||||||
if task.projectDir == "" && tc.Name == "exec" {
|
if task.projectDir == "" {
|
||||||
if cmd, _ := tc.Arguments["command"].(string); cmd != "" {
|
task.projectDir = extractProjectDir(tc.Name, tc.Arguments, agent.Workspace)
|
||||||
task.projectDir = extractProjectDir(cmd, agent.Workspace)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
task.mu.Unlock()
|
task.mu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -1604,54 +1604,85 @@ func TestBuildRichStatus_ProjectDir(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractProjectDir(t *testing.T) {
|
func TestExtractProjectDir(t *testing.T) {
|
||||||
|
ws := "/home/user/.picoclaw/workspace"
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
cmd string
|
toolName string
|
||||||
|
args map[string]interface{}
|
||||||
workspace string
|
workspace string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
|
// exec: cd prefix
|
||||||
{
|
{
|
||||||
name: "cd into projects subdir",
|
name: "exec cd into projects subdir",
|
||||||
cmd: "cd /home/user/.picoclaw/workspace/projects/terra-py-form && pytest",
|
toolName: "exec",
|
||||||
workspace: "/home/user/.picoclaw/workspace",
|
args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/projects/terra-py-form && pytest"},
|
||||||
|
workspace: ws,
|
||||||
want: "terra-py-form",
|
want: "terra-py-form",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "cd into projects subdir with trailing slash",
|
name: "exec cd with trailing slash workspace",
|
||||||
cmd: "cd /home/user/.picoclaw/workspace/projects/terra-py-form && ls",
|
toolName: "exec",
|
||||||
workspace: "/home/user/.picoclaw/workspace/",
|
args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/projects/terra-py-form && ls"},
|
||||||
|
workspace: ws + "/",
|
||||||
want: "terra-py-form",
|
want: "terra-py-form",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "cd into direct subdir",
|
name: "exec cd into direct subdir",
|
||||||
cmd: "cd /home/user/.picoclaw/workspace/my-app && make build",
|
toolName: "exec",
|
||||||
workspace: "/home/user/.picoclaw/workspace",
|
args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/my-app && make build"},
|
||||||
|
workspace: ws,
|
||||||
want: "my-app",
|
want: "my-app",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "cd to workspace itself",
|
name: "exec cd to workspace itself",
|
||||||
cmd: "cd /home/user/.picoclaw/workspace && ls",
|
toolName: "exec",
|
||||||
workspace: "/home/user/.picoclaw/workspace",
|
args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace && ls"},
|
||||||
|
workspace: ws,
|
||||||
want: "",
|
want: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no cd prefix",
|
name: "exec no cd prefix",
|
||||||
cmd: "pytest tests/",
|
toolName: "exec",
|
||||||
workspace: "/home/user/.picoclaw/workspace",
|
args: map[string]interface{}{"command": "pytest tests/"},
|
||||||
|
workspace: ws,
|
||||||
want: "",
|
want: "",
|
||||||
},
|
},
|
||||||
|
// file tools
|
||||||
{
|
{
|
||||||
name: "cd to unrelated path",
|
name: "read_file in projects subdir",
|
||||||
cmd: "cd /tmp/build && make",
|
toolName: "read_file",
|
||||||
workspace: "/home/user/.picoclaw/workspace",
|
args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/projects/terra-py-form/src/main.py"},
|
||||||
want: "build",
|
workspace: ws,
|
||||||
|
want: "terra-py-form",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "edit_file in direct subdir",
|
||||||
|
toolName: "edit_file",
|
||||||
|
args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/my-app/README.md"},
|
||||||
|
workspace: ws,
|
||||||
|
want: "my-app",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "write_file at workspace root",
|
||||||
|
toolName: "write_file",
|
||||||
|
args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/notes.txt"},
|
||||||
|
workspace: ws,
|
||||||
|
want: "notes.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown tool returns empty",
|
||||||
|
toolName: "web_search",
|
||||||
|
args: map[string]interface{}{"query": "test"},
|
||||||
|
workspace: ws,
|
||||||
|
want: "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := extractProjectDir(tt.cmd, tt.workspace)
|
got := extractProjectDir(tt.toolName, tt.args, tt.workspace)
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Errorf("extractProjectDir(%q, %q) = %q, want %q", tt.cmd, tt.workspace, got, tt.want)
|
t.Errorf("extractProjectDir(%q, args, %q) = %q, want %q", tt.toolName, tt.workspace, got, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue