fix: use per-chat plan file paths (#24268)

> This PR was authored by Mux on behalf of Mike.

Chats sharing one workspace (e.g. sibling subagents) all wrote to
`/home/coder/PLAN.md`, causing plan file collisions. This change derives
a unique plan path per chat from the workspace home directory and chat
ID.

## Changes

* `write_file`, `edit_files`, and `propose_plan` reject any `plan.md`
variant (case-insensitive) at the workspace home root, with a clear
error pointing to the chat-specific path.
* Root chats receive a `<plan-file-path>` block inlined in the main
system prompt with the concrete path.
* Prompt and tool descriptions no longer hardcode `/home/coder/PLAN.md`.
* Plan path handling is POSIX-only (forward-slash), relying on the
contract that workspace agent paths are normalized before reaching
chatd.
* Updated `ProposePlanTool.stories.tsx` to use per-chat path examples.
* Full test coverage for plan path detection, legacy-path rejection in
all three tools, inline prompt rendering, and fallback behavior.
This commit is contained in:
Michael Suchacz
2026-04-14 10:50:40 +02:00
committed by GitHub
parent 116323d3cf
commit a554de372a
15 changed files with 1708 additions and 33 deletions
+133
View File
@@ -56,6 +56,7 @@ const (
DefaultInFlightChatStaleAfter = 5 * time.Minute
homeInstructionLookupTimeout = 5 * time.Second
planPathLookupTimeout = 5 * time.Second
instructionCacheTTL = 5 * time.Minute
workspaceDialValidationDelay = 5 * time.Second
workspaceMCPDiscoveryTimeout = 5 * time.Second
@@ -4475,6 +4476,50 @@ func (p *Server) runChat(
}
defer workspaceCtx.close()
planPathFn := func(ctx context.Context) (string, string, error) {
conn, err := workspaceCtx.getWorkspaceConn(ctx)
if err != nil {
return "", "", err
}
home, err := chattool.ResolveWorkspaceHome(ctx, conn)
if err != nil {
return "", "", err
}
return chattool.PlanPathForChat(home, chat.ID), home, nil
}
resolvePlanPathForTools := func(ctx context.Context) (string, string, error) {
ctx, cancel := context.WithTimeout(ctx, planPathLookupTimeout)
defer cancel()
return planPathFn(ctx)
}
resolvePlanPathBlock := func(resolveCtx context.Context) string {
if chat.ParentChatID.Valid {
return ""
}
planCtx, cancel := context.WithTimeout(resolveCtx, planPathLookupTimeout)
defer cancel()
if _, _, err := workspaceCtx.workspaceAgentIDForConn(planCtx); err != nil {
p.logger.Debug(resolveCtx, "plan path instruction: agent not reachable",
slog.Error(err),
slog.F("chat_id", chat.ID),
)
return ""
}
planPath, home, err := planPathFn(planCtx)
if err != nil {
p.logger.Debug(resolveCtx, "plan path instruction: failed to resolve plan path",
slog.Error(err),
slog.F("chat_id", chat.ID),
)
return ""
}
return formatPlanPathBlock(planPath, home)
}
// Connect to MCP servers in parallel with instruction
// resolution. ConnectAll only depends on mcpConfigs and
// mcpTokens which are available after g.Wait() above.
@@ -4668,6 +4713,7 @@ func (p *Server) runChat(
if instruction != "" {
prompt = chatprompt.InsertSystem(prompt, instruction)
}
prompt = renderPlanPathPrompt(prompt, resolvePlanPathBlock(ctx))
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
prompt = chatprompt.InsertSystem(prompt, skillIndex)
}
@@ -4980,9 +5026,11 @@ func (p *Server) runChat(
}),
chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
ResolvePlanPath: resolvePlanPathForTools,
}),
chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
ResolvePlanPath: resolvePlanPathForTools,
}),
chattool.Execute(chattool.ExecuteOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
@@ -5049,6 +5097,7 @@ func (p *Server) runChat(
// Plan presentation tool.
tools = append(tools, chattool.ProposePlan(chattool.ProposePlanOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
ResolvePlanPath: resolvePlanPathForTools,
StoreFile: func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error) {
workspaceCtx.chatStateMu.Lock()
chatSnapshot := *workspaceCtx.currentChat
@@ -5241,6 +5290,7 @@ func (p *Server) runChat(
if instruction != "" {
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, instruction)
}
reloadedPrompt = renderPlanPathPrompt(reloadedPrompt, resolvePlanPathBlock(reloadCtx))
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, skillIndex)
}
@@ -5912,6 +5962,89 @@ func (p *Server) resolveUserPrompt(ctx context.Context, userID uuid.UUID) string
return "<user-instructions>\n" + trimmed + "\n</user-instructions>"
}
// renderPlanPathPrompt fills the plan-path placeholder when it is
// present in the prompt.
func renderPlanPathPrompt(prompt []fantasy.Message, planPathBlock string) []fantasy.Message {
prompt, _ = replacePlanPathPlaceholder(prompt, planPathBlock)
return prompt
}
func replacePlanPathPlaceholder(
prompt []fantasy.Message,
planPathBlock string,
) ([]fantasy.Message, bool) {
var updatedPrompt []fantasy.Message
replaced := false
for i, message := range prompt {
updatedMessage, ok := replacePlanPathPlaceholderInMessage(message, planPathBlock)
if !ok {
continue
}
if updatedPrompt == nil {
updatedPrompt = slices.Clone(prompt)
}
updatedPrompt[i] = updatedMessage
replaced = true
}
if !replaced {
return prompt, false
}
return updatedPrompt, true
}
func replacePlanPathPlaceholderInMessage(
message fantasy.Message,
planPathBlock string,
) (fantasy.Message, bool) {
if message.Role != fantasy.MessageRoleSystem {
return message, false
}
content := slices.Clone(message.Content)
replaced := false
for i, part := range content {
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](part)
if !ok || !strings.Contains(textPart.Text, defaultSystemPromptPlanPathBlockPlaceholder) {
continue
}
replaced = true
content[i] = fantasy.TextPart{Text: strings.ReplaceAll(
textPart.Text,
defaultSystemPromptPlanPathBlockPlaceholder,
planPathBlock,
)}
}
if !replaced {
return message, false
}
message.Content = content
return message, true
}
func formatPlanPathBlock(chatPath, home string) string {
chatPath = strings.TrimSpace(chatPath)
if chatPath == "" {
return ""
}
avoidPlanPath := chattool.LegacySharedPlanPath
home = strings.TrimSpace(home)
if home != "" {
avoidPlanPath = strings.TrimRight(home, "/") + "/PLAN.md"
}
var b strings.Builder
_, _ = b.WriteString("<plan-file-path>\n")
_, _ = b.WriteString("Your plan file path for this chat is: ")
_, _ = b.WriteString(chatPath)
_, _ = b.WriteString("\n")
_, _ = b.WriteString("Always use this exact path when creating or proposing plan files. Do not use ")
_, _ = b.WriteString(avoidPlanPath)
_, _ = b.WriteString(".\n")
_, _ = b.WriteString("</plan-file-path>")
return b.String()
}
func (p *Server) recoverStaleChats(ctx context.Context) {
staleAfter := time.Now().Add(-p.inFlightChatStaleAfter)
staleChats, err := p.db.GetStaleChats(ctx, staleAfter)
+28
View File
@@ -5738,10 +5738,38 @@ func TestAgentContextFilesAndSkillsLoadedIntoChat(t *testing.T) {
require.Contains(t, allSystemContent, "AGENTS.md",
"system prompt should reference the source file")
planBlockCount := 0
standalonePlanBlockCount := 0
for _, msg := range recordedCalls[0] {
if msg.Role != "system" {
continue
}
planBlockCount += strings.Count(
msg.Content,
"<plan-file-path>\nYour plan file path for this chat is:",
)
trimmed := strings.TrimSpace(msg.Content)
if strings.HasPrefix(trimmed, "<plan-file-path>") &&
strings.HasSuffix(trimmed, "</plan-file-path>") {
standalonePlanBlockCount++
}
}
require.Contains(t, allSystemContent, "<available-skills>",
"system prompt should contain available-skills block")
require.Contains(t, allSystemContent, "my-cool-skill",
"system prompt should list the discovered skill")
require.Contains(t, allSystemContent, "A test skill",
"system prompt should include the skill description")
require.Contains(t, allSystemContent, "<plan-file-path>",
"system prompt should contain the plan-file-path block")
require.Contains(t, allSystemContent, "PLAN-"+chat.ID.String()+".md",
"system prompt should use the chat-specific plan path")
require.Contains(t, allSystemContent,
"Do not use "+strings.TrimRight(fakeHome, "/")+"/PLAN.md.",
"system prompt should warn against the home-root plan path")
require.Equal(t, 1, planBlockCount,
"system prompt should contain a single plan-file-path block")
require.Zero(t, standalonePlanBlockCount,
"plan-file-path block should be part of the main system prompt, not a standalone message")
}
+34 -1
View File
@@ -2,6 +2,7 @@ package chattool
import (
"context"
"strings"
"charm.land/fantasy"
@@ -10,6 +11,7 @@ import (
type EditFilesOptions struct {
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
ResolvePlanPath func(context.Context) (chatPath string, home string, err error)
}
type EditFilesArgs struct {
@@ -29,7 +31,7 @@ func EditFiles(options EditFilesOptions) fantasy.AgentTool {
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return executeEditFilesTool(ctx, conn, args)
return executeEditFilesTool(ctx, conn, args, options.ResolvePlanPath)
},
)
}
@@ -38,11 +40,42 @@ func executeEditFilesTool(
ctx context.Context,
conn workspacesdk.AgentConn,
args EditFilesArgs,
resolvePlanPath func(context.Context) (chatPath string, home string, err error),
) (fantasy.ToolResponse, error) {
if len(args.Files) == 0 {
return fantasy.NewTextErrorResponse("files is required"), nil
}
var (
chatPath string
home string
planPathErr error
planPathLoaded bool
)
for i := range args.Files {
args.Files[i].Path = strings.TrimSpace(args.Files[i].Path)
file := args.Files[i]
hasPlanFileName := looksLikePlanFileName(file.Path)
if hasPlanFileName && !isAbsolutePath(file.Path) {
return fantasy.NewTextErrorResponse(
"plan files must use absolute paths; use the chat-specific absolute plan path; no files in this batch were applied",
), nil
}
if resolvePlanPath == nil || !hasPlanFileName {
continue
}
if !planPathLoaded {
chatPath, home, planPathErr = resolvePlanPath(ctx)
planPathLoaded = true
}
if resp, rejected := rejectSharedPlanPath(file.Path, home, chatPath, planPathErr); rejected {
return fantasy.NewTextErrorResponse(
resp.Content + "; no files in this batch were applied",
), nil
}
}
if err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{Files: args.Files}); err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
+290
View File
@@ -0,0 +1,290 @@
package chattool_test
import (
"context"
"testing"
"charm.land/fantasy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock"
)
func TestEditFiles(t *testing.T) {
t.Parallel()
t.Run("RejectsPlanPathsWhenResolvePlanPathIsConfigured", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
expectedRejectedPath string
}{
{
name: "SingleHomeRootPlanPath",
input: `{"files":[{"path":"/Users/dev/plan.md","edits":[{"search":"old","replace":"new"}]}]}`,
expectedRejectedPath: "/Users/dev/plan.md",
},
{
name: "MultiFileBatchWithHomeRootPlanPath",
input: `{"files":[` +
`{"path":"/Users/dev/subdir/plan.md","edits":[{"search":"old","replace":"new"}]},` +
`{"path":"/Users/dev/plan.md","edits":[{"search":"old","replace":"new"}]}` +
`]}`,
expectedRejectedPath: "/Users/dev/plan.md",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
resolvePlanPathCalls := 0
tool := chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
resolvePlanPathCalls++
return "/Users/dev/.coder/plans/PLAN-chat.md", "/Users/dev", nil
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "edit_files",
Input: testCase.input,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Equal(t, 1, resolvePlanPathCalls)
assert.Equal(
t,
editFilesBatchRejectedMessage(sharedPlanPathResolvedMessage(
testCase.expectedRejectedPath,
"/Users/dev/.coder/plans/PLAN-chat.md",
)),
resp.Content,
)
})
}
})
t.Run("RejectsSharedPlanPathWhenResolverFails", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
tool := chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
return "", "", xerrors.New("workspace unavailable")
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "edit_files",
Input: `{"files":[{"path":"/home/coder/plan.md","edits":[{"search":"old","replace":"new"}]}]}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Equal(t, editFilesBatchRejectedMessage(planPathVerificationMessage("/home/coder/plan.md")), resp.Content)
})
t.Run("RejectsRelativePlanPathsWhenResolvePlanPathIsConfigured", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
resolvePlanPathCalled := false
tool := chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
resolvePlanPathCalled = true
return "/home/coder/.coder/plans/PLAN-chat.md", "/home/coder", nil
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "edit_files",
Input: `{"files":[{"path":"plan.md","edits":[{"search":"old","replace":"new"}]}]}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.False(t, resolvePlanPathCalled)
assert.Equal(t, editFilesBatchRejectedMessage(relativePlanPathMessage()), resp.Content)
})
t.Run("PerChatPlanPathIsAllowed", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
chatPlanPath := "/home/coder/.coder/plans/PLAN-123e4567-e89b-12d3-a456-426614174000.md"
request := workspacesdk.FileEditRequest{Files: []workspacesdk.FileEdits{{
Path: chatPlanPath,
Edits: []workspacesdk.FileEdit{{
Search: "old",
Replace: "new",
}},
}}}
mockConn.EXPECT().EditFiles(gomock.Any(), request).Return(nil)
resolvePlanPathCalled := false
tool := chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
resolvePlanPathCalled = true
return chatPlanPath, "/home/coder", nil
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "edit_files",
Input: `{"files":[{"path":"` + chatPlanPath + `","edits":[{"search":"old","replace":"new"}]}]}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.False(t, resolvePlanPathCalled)
})
t.Run("NestedPlanPathAllowedWhenResolverFails", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
request := workspacesdk.FileEditRequest{Files: []workspacesdk.FileEdits{{
Path: "/home/coder/myproject/plan.md",
Edits: []workspacesdk.FileEdit{{
Search: "old",
Replace: "new",
}},
}}}
mockConn.EXPECT().EditFiles(gomock.Any(), request).Return(nil)
tool := chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
return "", "", xerrors.New("workspace unavailable")
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "edit_files",
Input: `{"files":[{"path":"/home/coder/myproject/plan.md","edits":[{"search":"old","replace":"new"}]}]}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
})
t.Run("NestedPlanPathUnderHomeIsAllowed", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
request := workspacesdk.FileEditRequest{Files: []workspacesdk.FileEdits{{
Path: "/home/coder/myproject/plan.md",
Edits: []workspacesdk.FileEdit{{
Search: "old",
Replace: "new",
}},
}}}
mockConn.EXPECT().EditFiles(gomock.Any(), request).Return(nil)
planPathCalled := false
tool := chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
planPathCalled = true
return "/home/coder/.coder/plans/PLAN-chat.md", "/home/coder", nil
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "edit_files",
Input: `{"files":[{"path":"/home/coder/myproject/plan.md","edits":[{"search":"old","replace":"new"}]}]}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.True(t, planPathCalled)
})
t.Run("AllowsNonSharedPath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
request := workspacesdk.FileEditRequest{Files: []workspacesdk.FileEdits{{
Path: "/home/dev/my-plan.md",
Edits: []workspacesdk.FileEdit{{
Search: "old",
Replace: "new",
}},
}}}
mockConn.EXPECT().EditFiles(gomock.Any(), request).Return(nil)
resolvePlanPathCalled := false
tool := chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
resolvePlanPathCalled = true
return "", "", xerrors.New("should not be called")
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "edit_files",
Input: `{"files":[{"path":"/home/dev/my-plan.md","edits":[{"search":"old","replace":"new"}]}]}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.False(t, resolvePlanPathCalled)
})
t.Run("AllowsSharedPlanPathWhenResolvePlanPathIsNil", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
request := workspacesdk.FileEditRequest{Files: []workspacesdk.FileEdits{{
Path: chattool.LegacySharedPlanPath,
Edits: []workspacesdk.FileEdit{{
Search: "old",
Replace: "new",
}},
}}}
mockConn.EXPECT().EditFiles(gomock.Any(), request).Return(nil)
tool := chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "edit_files",
Input: `{"files":[{"path":"` + chattool.LegacySharedPlanPath + `","edits":[{"search":"old","replace":"new"}]}]}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
})
}
+90
View File
@@ -0,0 +1,90 @@
package chattool
import (
"context"
"path"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
const planFileNamePrefix = "PLAN-"
// LegacySharedPlanPath is the original shared plan file path used by
// every chat in a workspace.
const LegacySharedPlanPath = "/home/coder/PLAN.md"
// ResolveWorkspaceHome returns the workspace user's home directory.
func ResolveWorkspaceHome(
ctx context.Context,
conn workspacesdk.AgentConn,
) (string, error) {
if conn == nil {
return "", xerrors.New("workspace connection is required")
}
resp, err := conn.LS(ctx, "", workspacesdk.LSRequest{
Path: []string{},
Relativity: workspacesdk.LSRelativityHome,
})
if err != nil {
return "", xerrors.Errorf("resolve workspace home: %w", err)
}
home := strings.TrimSpace(resp.AbsolutePathString)
if home == "" {
return "", xerrors.New("workspace home path is empty")
}
return home, nil
}
// PlanPathForChat returns the per-chat plan file path rooted in the
// workspace home directory.
func PlanPathForChat(home string, chatID uuid.UUID) string {
return path.Join(
home,
".coder",
"plans",
planFileNamePrefix+chatID.String()+".md",
)
}
// chatd consumes agent-normalized POSIX paths. Workspace agents are
// expected to convert separators to forward slashes before these
// helpers run.
// isAbsolutePath reports whether p is an absolute POSIX path.
func isAbsolutePath(p string) bool {
return path.IsAbs(p)
}
// looksLikePlanFileName reports whether the base name of requestedPath
// is "plan.md" (case-insensitive), ignoring the directory component.
func looksLikePlanFileName(requestedPath string) bool {
cleaned := path.Clean(requestedPath)
return strings.EqualFold(path.Base(cleaned), "plan.md")
}
// LooksLikeHomePlanFile reports whether requestedPath is a plan.md
// variant (case-insensitive) sitting directly in the workspace home
// directory.
// The filename is compared case-insensitively because LLM output varies.
func LooksLikeHomePlanFile(requestedPath, home string) bool {
normalized := path.Clean(requestedPath)
normalizedHome := path.Clean(home)
return looksLikePlanFileName(normalized) &&
strings.EqualFold(path.Dir(normalized), normalizedHome)
}
// looksLikeLegacySharedPlanPath reports whether requestedPath
// matches the legacy shared plan path (case-insensitive). Used as a
// narrow fallback when the workspace home cannot be resolved.
func looksLikeLegacySharedPlanPath(requestedPath string) bool {
normalized := path.Clean(requestedPath)
return strings.EqualFold(normalized, LegacySharedPlanPath)
}
@@ -0,0 +1,132 @@
package chattool
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestIsAbsolutePath(t *testing.T) {
t.Parallel()
tests := []struct {
path string
want bool
}{
{"/home/coder/PLAN.md", true},
{"/workspace/project/plan.md", true},
{"plan.md", false},
{"./plan.md", false},
{"../plan.md", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, isAbsolutePath(tt.path))
})
}
}
func TestLooksLikePlanFileName(t *testing.T) {
t.Parallel()
require.True(t, looksLikePlanFileName("plan.md"))
require.True(t, looksLikePlanFileName("./Plan.md"))
require.True(t, looksLikePlanFileName("/home/coder/PLAN.md"))
require.False(t, looksLikePlanFileName("/home/coder/README.md"))
}
func TestLooksLikeLegacySharedPlanPath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requested string
want bool
}{
{
name: "ExactMatch",
requested: "/home/coder/PLAN.md",
want: true,
},
{
name: "CaseInsensitive",
requested: "/home/coder/plan.md",
want: true,
},
{
name: "MixedCase",
requested: "/home/coder/Plan.md",
want: true,
},
{
name: "NestedPath",
requested: "/home/coder/myproject/plan.md",
want: false,
},
{
name: "DifferentHome",
requested: "/Users/dev/PLAN.md",
want: false,
},
{
name: "PerChatPath",
requested: "/home/coder/.coder/plans/PLAN-123e4567-e89b-12d3-a456-426614174000.md",
want: false,
},
{
name: "EmptyString",
requested: "",
want: false,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, testCase.want, looksLikeLegacySharedPlanPath(testCase.requested))
})
}
}
func TestRejectSharedPlanPath(t *testing.T) {
t.Parallel()
resp, rejected := rejectSharedPlanPath(
LegacySharedPlanPath,
"/Users/dev",
"/Users/dev/.coder/plans/PLAN-chat.md",
nil,
)
require.True(t, rejected)
require.True(t, resp.IsError)
require.Equal(
t,
sharedPlanPathMessage(
LegacySharedPlanPath,
"/Users/dev/.coder/plans/PLAN-chat.md",
),
resp.Content,
)
}
func TestSharedPlanPathMessage(t *testing.T) {
t.Parallel()
require.Equal(
t,
"the plan path /home/coder/plan.md is no longer supported at the home root; use the chat-specific plan path: /home/coder/.coder/plans/PLAN-chat.md",
sharedPlanPathMessage(
"/home/coder/plan.md",
"/home/coder/.coder/plans/PLAN-chat.md",
),
)
require.Equal(
t,
"the plan path /home/coder/plan.md could not be verified because the workspace is currently unavailable to resolve the chat-specific plan path, try again shortly",
planPathVerificationMessage("/home/coder/plan.md"),
)
}
+219
View File
@@ -0,0 +1,219 @@
package chattool_test
import (
"context"
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock"
)
func TestResolveWorkspaceHome(t *testing.T) {
t.Parallel()
tests := []struct {
name string
resp workspacesdk.LSResponse
lsErr error
want string
wantErr bool
errMatch string
}{
{
name: "StandardLinuxHome",
resp: workspacesdk.LSResponse{AbsolutePathString: "/home/coder"},
want: "/home/coder",
},
{
name: "NonStandardHome",
resp: workspacesdk.LSResponse{AbsolutePathString: "/Users/dev"},
want: "/Users/dev",
},
{
name: "LSError",
lsErr: xerrors.New("list failed"),
wantErr: true,
errMatch: "list failed",
},
{
name: "EmptyAbsolutePathString",
resp: workspacesdk.LSResponse{AbsolutePathString: ""},
wantErr: true,
errMatch: "workspace home path is empty",
},
{
name: "WhitespaceOnlyAbsolutePathString",
resp: workspacesdk.LSResponse{AbsolutePathString: " \t\n "},
wantErr: true,
errMatch: "workspace home path is empty",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
conn := agentconnmock.NewMockAgentConn(ctrl)
conn.EXPECT().LS(
gomock.Any(),
"",
workspacesdk.LSRequest{
Path: []string{},
Relativity: workspacesdk.LSRelativityHome,
},
).Return(testCase.resp, testCase.lsErr)
got, err := chattool.ResolveWorkspaceHome(context.Background(), conn)
if testCase.wantErr {
require.Error(t, err)
require.ErrorContains(t, err, testCase.errMatch)
require.Empty(t, got)
return
}
require.NoError(t, err)
require.Equal(t, testCase.want, got)
})
}
}
func TestPlanPathForChat(t *testing.T) {
t.Parallel()
t.Run("StandardHome", func(t *testing.T) {
t.Parallel()
chatID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")
got := chattool.PlanPathForChat("/home/coder", chatID)
require.Equal(
t,
"/home/coder/.coder/plans/PLAN-123e4567-e89b-12d3-a456-426614174000.md",
got,
)
})
t.Run("NonStandardHome", func(t *testing.T) {
t.Parallel()
chatID := uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
got := chattool.PlanPathForChat("/Users/dev", chatID)
require.Equal(
t,
"/Users/dev/.coder/plans/PLAN-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.md",
got,
)
})
t.Run("MatchesExpectedFormat", func(t *testing.T) {
t.Parallel()
home := "/workspace/home"
chatID := uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")
got := chattool.PlanPathForChat(home, chatID)
require.True(t, strings.HasPrefix(got, home+"/.coder/plans/PLAN-"))
require.True(t, strings.HasSuffix(got, chatID.String()+".md"))
})
}
func TestLooksLikeHomePlanFile(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requested string
home string
want bool
}{
{
name: "UppercaseHomeRootPlan",
requested: "/home/coder/PLAN.md",
home: "/home/coder",
want: true,
},
{
name: "LowercaseHomeRootPlan",
requested: "/home/coder/plan.md",
home: "/home/coder",
want: true,
},
{
name: "MixedCaseHomeRootPlan",
requested: "/home/coder/Plan.md",
home: "/home/coder",
want: true,
},
{
name: "UppercaseExtension",
requested: "/home/coder/PLAN.MD",
home: "/home/coder",
want: true,
},
{
name: "CustomHomeRootPlan",
requested: "/Users/dev/plan.md",
home: "/Users/dev",
want: true,
},
{
name: "NestedPlanUnderHome",
requested: "/home/coder/myproject/plan.md",
home: "/home/coder",
want: false,
},
{
name: "PerChatPlanPath",
requested: "/home/coder/.coder/plans/PLAN-123e4567-e89b-12d3-a456-426614174000.md",
home: "/home/coder",
want: false,
},
{
name: "DifferentFilename",
requested: "/home/coder/README.md",
home: "/home/coder",
want: false,
},
{
name: "DifferentExtension",
requested: "/home/coder/plan.txt",
home: "/home/coder",
want: false,
},
{
name: "EmptyPath",
requested: "",
home: "/home/coder",
want: false,
},
{
name: "DifferentHomeMismatch",
requested: "/home/coder/plan.md",
home: "/Users/dev",
want: false,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
got := chattool.LooksLikeHomePlanFile(testCase.requested, testCase.home)
require.Equal(t, testCase.want, got)
})
}
}
@@ -0,0 +1,54 @@
package chattool
import (
"fmt"
"charm.land/fantasy"
)
// rejectSharedPlanPath reports whether requestedPath targets the shared
// home-root plan file and, if so, returns a rejection response that
// points callers at the chat-specific plan path.
func rejectSharedPlanPath(
requestedPath string,
home string,
chatPath string,
planPathErr error,
) (fantasy.ToolResponse, bool) {
if planPathErr != nil {
// When the resolver fails, we cannot determine the actual
// home directory. Fall back to rejecting only the exact
// legacy shared path (case-insensitive) rather than every
// file named plan.md.
if !looksLikeLegacySharedPlanPath(requestedPath) {
return fantasy.ToolResponse{}, false
}
return fantasy.NewTextErrorResponse(
planPathVerificationMessage(requestedPath),
), true
}
if !LooksLikeHomePlanFile(requestedPath, home) && !looksLikeLegacySharedPlanPath(requestedPath) {
return fantasy.ToolResponse{}, false
}
return fantasy.NewTextErrorResponse(
sharedPlanPathMessage(requestedPath, chatPath),
), true
}
func sharedPlanPathMessage(requestedPath, chatPath string) string {
return fmt.Sprintf(
"the plan path %s is no longer supported at the home root; use the chat-specific plan path: %s",
requestedPath,
chatPath,
)
}
func planPathVerificationMessage(requestedPath string) string {
return fmt.Sprintf(
"the plan path %s could not be verified because the workspace is currently unavailable to resolve the chat-specific plan path, try again shortly",
requestedPath,
)
}
+27 -10
View File
@@ -17,6 +17,7 @@ const maxProposePlanSize = 32 * 1024 // 32 KiB
// ProposePlanOptions configures the propose_plan tool.
type ProposePlanOptions struct {
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
ResolvePlanPath func(context.Context) (chatPath string, home string, err error)
StoreFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error)
}
@@ -31,8 +32,9 @@ func ProposePlan(options ProposePlanOptions) fantasy.AgentTool {
return fantasy.NewAgentTool(
"propose_plan",
"Present a Markdown plan file from the workspace for user review. "+
"The file must already exist with a .md extension — use write_file to create it or edit_files to refine it before calling this tool. "+
"Pass the absolute file path (e.g. /home/coder/PLAN.md). The tool reads the content from the workspace.",
"The file must already exist with a .md extension. Use write_file to create it or edit_files to refine it before calling this tool. "+
"Pass the absolute file path to the plan. Important: use the chat-specific absolute plan path, not a generic path like PLAN.md in the home directory. "+
"The tool reads the content from the workspace.",
func(ctx context.Context, args ProposePlanArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if options.GetWorkspaceConn == nil {
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
@@ -44,7 +46,7 @@ func ProposePlan(options ProposePlanOptions) fantasy.AgentTool {
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return executeProposePlanTool(ctx, conn, args, options.StoreFile)
return executeProposePlanTool(ctx, conn, args, options.ResolvePlanPath, options.StoreFile)
},
)
}
@@ -53,17 +55,32 @@ func executeProposePlanTool(
ctx context.Context,
conn workspacesdk.AgentConn,
args ProposePlanArgs,
resolvePlanPath func(context.Context) (chatPath string, home string, err error),
storeFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error),
) (fantasy.ToolResponse, error) {
path := strings.TrimSpace(args.Path)
if path == "" {
return fantasy.NewTextErrorResponse("path is required (use an absolute path, e.g. /home/coder/PLAN.md)"), nil
requestedPath := strings.TrimSpace(args.Path)
if requestedPath == "" {
return fantasy.NewTextErrorResponse("path is required (use the chat-specific absolute plan path)"), nil
}
if !strings.HasSuffix(path, ".md") {
if !strings.HasSuffix(requestedPath, ".md") {
return fantasy.NewTextErrorResponse("path must end with .md"), nil
}
rc, _, err := conn.ReadFile(ctx, path, 0, maxProposePlanSize+1)
hasPlanFileName := looksLikePlanFileName(requestedPath)
if hasPlanFileName && !isAbsolutePath(requestedPath) {
return fantasy.NewTextErrorResponse(
"plan files must use absolute paths; use the chat-specific absolute plan path",
), nil
}
if resolvePlanPath != nil && hasPlanFileName {
chatPath, home, err := resolvePlanPath(ctx)
if resp, rejected := rejectSharedPlanPath(requestedPath, home, chatPath, err); rejected {
return resp, nil
}
}
rc, _, err := conn.ReadFile(ctx, requestedPath, 0, maxProposePlanSize+1)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
@@ -77,14 +94,14 @@ func executeProposePlanTool(
return fantasy.NewTextErrorResponse("plan file exceeds 32 KiB size limit"), nil
}
fileID, err := storeFile(ctx, filepath.Base(path), "text/markdown", data)
fileID, err := storeFile(ctx, filepath.Base(requestedPath), "text/markdown", data)
if err != nil {
return fantasy.NewTextErrorResponse("failed to store plan file: " + err.Error()), nil
}
return toolResponse(map[string]any{
"ok": true,
"path": path,
"path": requestedPath,
"kind": "plan",
"file_id": fileID.String(),
"media_type": "text/markdown",
+226 -2
View File
@@ -82,6 +82,34 @@ func TestProposePlan(t *testing.T) {
assert.Contains(t, resp.Content, "path must end with .md")
})
t.Run("RelativePlanPathReturnsError", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
storeFile, _ := fakeStoreFile(t)
resolvePlanPathCalled := false
tool := newProposePlanToolWithPlanPath(
t,
mockConn,
storeFile,
func(context.Context) (string, string, error) {
resolvePlanPathCalled = true
return "/home/coder/.coder/plans/PLAN-chat.md", "/home/coder", nil
},
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "propose_plan",
Input: `{"path":"plan.md"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.False(t, resolvePlanPathCalled)
assert.Equal(t, relativePlanPathMessage(), resp.Content)
})
t.Run("OversizedFileRejected", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
@@ -135,7 +163,16 @@ func TestProposePlan(t *testing.T) {
Return(io.NopCloser(strings.NewReader("# Plan\n\nContent")), "text/markdown", nil)
storeFile, stored := fakeStoreFile(t)
tool := newProposePlanTool(t, mockConn, storeFile)
planPathCalled := false
tool := newProposePlanToolWithPlanPath(
t,
mockConn,
storeFile,
func(context.Context) (string, string, error) {
planPathCalled = true
return "/home/coder/.coder/plans/PLAN-xxx.md", "/home/coder", nil
},
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "propose_plan",
@@ -143,6 +180,7 @@ func TestProposePlan(t *testing.T) {
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.True(t, planPathCalled)
result := decodeProposePlanResponse(t, resp)
assert.True(t, result.OK)
@@ -154,6 +192,41 @@ func TestProposePlan(t *testing.T) {
assert.NotContains(t, resp.Content, "content")
})
t.Run("NestedPlanPathUnderHomeIsAllowed", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
mockConn.EXPECT().
ReadFile(gomock.Any(), "/home/coder/myproject/plan.md", int64(0), int64(32*1024+1)).
Return(io.NopCloser(strings.NewReader("# Nested Plan")), "text/markdown", nil)
storeFile, stored := fakeStoreFile(t)
planPathCalled := false
tool := newProposePlanToolWithPlanPath(
t,
mockConn,
storeFile,
func(context.Context) (string, string, error) {
planPathCalled = true
return "/home/coder/.coder/plans/PLAN-chat.md", "/home/coder", nil
},
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "propose_plan",
Input: `{"path":"/home/coder/myproject/plan.md"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.True(t, planPathCalled)
result := decodeProposePlanResponse(t, resp)
assert.True(t, result.OK)
assert.Equal(t, "/home/coder/myproject/plan.md", result.Path)
assert.Equal(t, []byte("# Nested Plan"), *stored)
})
t.Run("FileNotFound", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
@@ -218,6 +291,128 @@ func TestProposePlan(t *testing.T) {
assert.Contains(t, resp.Content, "storage unavailable")
})
t.Run("RejectsSharedPlanPathWithResolvedPath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
storeFile, _ := fakeStoreFile(t)
tool := newProposePlanToolWithPlanPath(
t,
mockConn,
storeFile,
func(context.Context) (string, string, error) {
return "/home/coder/.coder/plans/PLAN-chat.md", "/home/coder", nil
},
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "propose_plan",
Input: `{"path":"` + chattool.LegacySharedPlanPath + `"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Equal(
t,
sharedPlanPathResolvedMessage(chattool.LegacySharedPlanPath, "/home/coder/.coder/plans/PLAN-chat.md"),
resp.Content,
)
})
t.Run("RejectsSharedPlanPathWhenResolverFails", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
storeFile, _ := fakeStoreFile(t)
tool := newProposePlanToolWithPlanPath(
t,
mockConn,
storeFile,
func(context.Context) (string, string, error) {
return "", "", xerrors.New("workspace unavailable")
},
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "propose_plan",
Input: `{"path":"` + chattool.LegacySharedPlanPath + `"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Equal(t, planPathVerificationMessage(chattool.LegacySharedPlanPath), resp.Content)
})
t.Run("PerChatPlanPathIsAllowed", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
chatPlanPath := "/home/coder/.coder/plans/PLAN-123e4567-e89b-12d3-a456-426614174000.md"
mockConn.EXPECT().
ReadFile(gomock.Any(), chatPlanPath, int64(0), int64(32*1024+1)).
Return(io.NopCloser(strings.NewReader("# Per-Chat Plan")), "text/markdown", nil)
storeFile, stored := fakeStoreFile(t)
resolvePlanPathCalled := false
tool := newProposePlanToolWithPlanPath(
t,
mockConn,
storeFile,
func(context.Context) (string, string, error) {
resolvePlanPathCalled = true
return chatPlanPath, "/home/coder", nil
},
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "propose_plan",
Input: `{"path":"` + chatPlanPath + `"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.False(t, resolvePlanPathCalled)
result := decodeProposePlanResponse(t, resp)
assert.True(t, result.OK)
assert.Equal(t, chatPlanPath, result.Path)
assert.Equal(t, []byte("# Per-Chat Plan"), *stored)
})
t.Run("NestedPlanPathAllowedWhenResolverFails", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
mockConn.EXPECT().
ReadFile(gomock.Any(), "/home/coder/myproject/plan.md", int64(0), int64(32*1024+1)).
Return(io.NopCloser(strings.NewReader("# Nested Plan")), "text/markdown", nil)
storeFile, stored := fakeStoreFile(t)
tool := newProposePlanToolWithPlanPath(
t,
mockConn,
storeFile,
func(context.Context) (string, string, error) {
return "", "", xerrors.New("workspace unavailable")
},
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "propose_plan",
Input: `{"path":"/home/coder/myproject/plan.md"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
result := decodeProposePlanResponse(t, resp)
assert.True(t, result.OK)
assert.Equal(t, "/home/coder/myproject/plan.md", result.Path)
assert.Equal(t, []byte("# Nested Plan"), *stored)
})
t.Run("WorkspaceConnectionError", func(t *testing.T) {
t.Parallel()
storeFile, _ := fakeStoreFile(t)
@@ -278,16 +473,45 @@ func newProposePlanTool(
t *testing.T,
mockConn *agentconnmock.MockAgentConn,
storeFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error),
) fantasy.AgentTool {
t.Helper()
return newProposePlanToolWithPlanPath(t, mockConn, storeFile, nil)
}
func newProposePlanToolWithPlanPath(
t *testing.T,
mockConn *agentconnmock.MockAgentConn,
storeFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error),
resolvePlanPath func(context.Context) (string, string, error),
) fantasy.AgentTool {
t.Helper()
return chattool.ProposePlan(chattool.ProposePlanOptions{
GetWorkspaceConn: func(_ context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
StoreFile: storeFile,
ResolvePlanPath: resolvePlanPath,
StoreFile: storeFile,
})
}
func sharedPlanPathResolvedMessage(requestedPath, planPath string) string {
return "the plan path " + requestedPath +
" is no longer supported at the home root; use the chat-specific plan path: " + planPath
}
func planPathVerificationMessage(requestedPath string) string {
return "the plan path " + requestedPath +
" could not be verified because the workspace is currently unavailable to resolve the chat-specific plan path, try again shortly"
}
func editFilesBatchRejectedMessage(message string) string {
return message + "; no files in this batch were applied"
}
func relativePlanPathMessage() string {
return "plan files must use absolute paths; use the chat-specific absolute plan path"
}
func fakeStoreFile(t *testing.T) (func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error), *[]byte) {
t.Helper()
+20 -3
View File
@@ -11,6 +11,7 @@ import (
type WriteFileOptions struct {
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
ResolvePlanPath func(context.Context) (chatPath string, home string, err error)
}
type WriteFileArgs struct {
@@ -30,7 +31,7 @@ func WriteFile(options WriteFileOptions) fantasy.AgentTool {
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return executeWriteFileTool(ctx, conn, args)
return executeWriteFileTool(ctx, conn, args, options.ResolvePlanPath)
},
)
}
@@ -39,12 +40,28 @@ func executeWriteFileTool(
ctx context.Context,
conn workspacesdk.AgentConn,
args WriteFileArgs,
resolvePlanPath func(context.Context) (chatPath string, home string, err error),
) (fantasy.ToolResponse, error) {
if args.Path == "" {
requestedPath := strings.TrimSpace(args.Path)
if requestedPath == "" {
return fantasy.NewTextErrorResponse("path is required"), nil
}
if err := conn.WriteFile(ctx, args.Path, strings.NewReader(args.Content)); err != nil {
hasPlanFileName := looksLikePlanFileName(requestedPath)
if hasPlanFileName && !isAbsolutePath(requestedPath) {
return fantasy.NewTextErrorResponse(
"plan files must use absolute paths; use the chat-specific absolute plan path",
), nil
}
if resolvePlanPath != nil && hasPlanFileName {
chatPath, home, err := resolvePlanPath(ctx)
if resp, rejected := rejectSharedPlanPath(requestedPath, home, chatPath, err); rejected {
return resp, nil
}
}
if err := conn.WriteFile(ctx, requestedPath, strings.NewReader(args.Content)); err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
return toolResponse(map[string]any{"ok": true}), nil
+321
View File
@@ -0,0 +1,321 @@
package chattool_test
import (
"context"
"io"
"strings"
"testing"
"charm.land/fantasy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock"
)
func TestWriteFile(t *testing.T) {
t.Parallel()
t.Run("RejectsHomeRootPlanVariantsWhenResolvePlanPathIsConfigured", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requested string
home string
}{
{
name: "ExactLegacyPath",
requested: chattool.LegacySharedPlanPath,
home: "/home/coder",
},
{
name: "LowercasePlanAtHomeRoot",
requested: "/home/coder/plan.md",
home: "/home/coder",
},
{
name: "MixedCasePlanAtHomeRoot",
requested: "/home/coder/Plan.md",
home: "/home/coder",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
tool := chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
return "/home/coder/.coder/plans/PLAN-chat.md", testCase.home, nil
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "write_file",
Input: `{"path":"` + testCase.requested + `","content":"# Plan"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Equal(
t,
sharedPlanPathResolvedMessage(
testCase.requested,
"/home/coder/.coder/plans/PLAN-chat.md",
),
resp.Content,
)
})
}
})
t.Run("RejectsRelativePlanPathsWhenResolvePlanPathIsConfigured", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requested string
}{
{
name: "PlainRelativePath",
requested: "plan.md",
},
{
name: "DotSlashRelativePath",
requested: "./plan.md",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
resolvePlanPathCalled := false
tool := chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
resolvePlanPathCalled = true
return "/home/coder/.coder/plans/PLAN-chat.md", "/home/coder", nil
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "write_file",
Input: `{"path":"` + testCase.requested + `","content":"# Plan"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.False(t, resolvePlanPathCalled)
assert.Equal(t, relativePlanPathMessage(), resp.Content)
})
}
})
t.Run("RejectsSharedPlanPathWhenResolverFails", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
tool := chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
return "", "", xerrors.New("workspace unavailable")
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "write_file",
Input: `{"path":"/home/coder/plan.md","content":"# Plan"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Equal(t, planPathVerificationMessage("/home/coder/plan.md"), resp.Content)
})
t.Run("PerChatPlanPathIsAllowed", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
chatPlanPath := "/home/coder/.coder/plans/PLAN-123e4567-e89b-12d3-a456-426614174000.md"
mockConn.EXPECT().
WriteFile(gomock.Any(), chatPlanPath, gomock.Any()).
DoAndReturn(func(_ context.Context, path string, reader io.Reader) error {
data, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, chatPlanPath, path)
require.Equal(t, "# Plan", string(data))
return nil
})
resolvePlanPathCalled := false
tool := chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
resolvePlanPathCalled = true
return chatPlanPath, "/home/coder", nil
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "write_file",
Input: `{"path":"` + chatPlanPath + `","content":"# Plan"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.False(t, resolvePlanPathCalled)
assert.Equal(t, `{"ok":true}`, strings.TrimSpace(resp.Content))
})
t.Run("NestedPlanPathAllowedWhenResolverFails", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
mockConn.EXPECT().
WriteFile(gomock.Any(), "/home/coder/myproject/plan.md", gomock.Any()).
DoAndReturn(func(_ context.Context, path string, reader io.Reader) error {
data, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, "/home/coder/myproject/plan.md", path)
require.Equal(t, "# Plan", string(data))
return nil
})
tool := chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
return "", "", xerrors.New("workspace unavailable")
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "write_file",
Input: `{"path":"/home/coder/myproject/plan.md","content":"# Plan"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.Equal(t, `{"ok":true}`, strings.TrimSpace(resp.Content))
})
t.Run("NestedPlanPathUnderHomeIsAllowed", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
mockConn.EXPECT().
WriteFile(gomock.Any(), "/home/coder/myproject/plan.md", gomock.Any()).
DoAndReturn(func(_ context.Context, path string, reader io.Reader) error {
data, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, "/home/coder/myproject/plan.md", path)
require.Equal(t, "# Plan", string(data))
return nil
})
planPathCalled := false
tool := chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
planPathCalled = true
return "/home/coder/.coder/plans/PLAN-chat.md", "/home/coder", nil
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "write_file",
Input: `{"path":"/home/coder/myproject/plan.md","content":"# Plan"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.True(t, planPathCalled)
assert.Equal(t, `{"ok":true}`, strings.TrimSpace(resp.Content))
})
t.Run("AllowsNonSharedPath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
mockConn.EXPECT().
WriteFile(gomock.Any(), "/home/dev/my-plan.md", gomock.Any()).
DoAndReturn(func(_ context.Context, path string, reader io.Reader) error {
data, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, "/home/dev/my-plan.md", path)
require.Equal(t, "# Plan", string(data))
return nil
})
resolvePlanPathCalled := false
tool := chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
resolvePlanPathCalled = true
return "", "", xerrors.New("should not be called")
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "write_file",
Input: `{"path":"/home/dev/my-plan.md","content":"# Plan"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.False(t, resolvePlanPathCalled)
assert.Equal(t, `{"ok":true}`, strings.TrimSpace(resp.Content))
})
t.Run("AllowsSharedPlanPathWhenResolvePlanPathIsNil", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
mockConn.EXPECT().
WriteFile(gomock.Any(), chattool.LegacySharedPlanPath, gomock.Any()).
DoAndReturn(func(_ context.Context, _ string, reader io.Reader) error {
data, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, "# Plan", string(data))
return nil
})
tool := chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "write_file",
Input: `{"path":"` + chattool.LegacySharedPlanPath + `","content":"# Plan"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
})
}
+102
View File
@@ -8,9 +8,111 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
)
func TestRenderPlanPathPrompt(t *testing.T) {
t.Parallel()
newPromptWithPlaceholder := func() []fantasy.Message {
return []fantasy.Message{
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "<planning>\n" + defaultSystemPromptPlanPathBlockPlaceholder + "\n</planning>"},
},
},
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "hello"},
},
},
}
}
messageText := func(t *testing.T, message fantasy.Message) string {
t.Helper()
part, ok := fantasy.AsMessagePart[fantasy.TextPart](message.Content[0])
require.True(t, ok)
return part.Text
}
t.Run("ReplacesPlaceholderWithResolvedHome", func(t *testing.T) {
t.Parallel()
prompt := newPromptWithPlaceholder()
got := renderPlanPathPrompt(prompt, formatPlanPathBlock(
"/Users/dev/.coder/plans/PLAN-chat.md",
"/Users/dev",
))
require.Len(t, got, len(prompt))
text := messageText(t, got[0])
require.Contains(t, text, "Your plan file path for this chat is: /Users/dev/.coder/plans/PLAN-chat.md")
require.Contains(t, text, "Do not use /Users/dev/PLAN.md.")
require.NotContains(t, text, defaultSystemPromptPlanPathBlockPlaceholder)
})
t.Run("FallsBackToLegacySharedPathWhenHomeIsEmpty", func(t *testing.T) {
t.Parallel()
prompt := newPromptWithPlaceholder()
got := renderPlanPathPrompt(prompt, formatPlanPathBlock(
"/home/coder/.coder/plans/PLAN-chat.md",
"",
))
text := messageText(t, got[0])
require.Contains(t, text, "Do not use "+chattool.LegacySharedPlanPath+".")
})
t.Run("LeavesPromptUnchangedWhenPlaceholderMissing", func(t *testing.T) {
t.Parallel()
prompt := []fantasy.Message{
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "base instructions"},
},
},
{
Role: fantasy.MessageRoleSystem,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "workspace awareness"},
},
},
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "hello"},
},
},
}
got := renderPlanPathPrompt(prompt, formatPlanPathBlock(
"/home/coder/.coder/plans/PLAN-chat.md",
"/home/coder",
))
require.Equal(t, prompt, got)
})
t.Run("RemovesPlaceholderWhenPlanPathBlockIsEmpty", func(t *testing.T) {
t.Parallel()
prompt := newPromptWithPlaceholder()
got := renderPlanPathPrompt(prompt, "")
require.Len(t, got, len(prompt))
text := messageText(t, got[0])
require.NotContains(t, text, defaultSystemPromptPlanPathBlockPlaceholder)
require.NotContains(t, text, "<plan-file-path>")
})
}
func TestInsertSystemInstructionAfterSystemMessages(t *testing.T) {
t.Parallel()
+10 -3
View File
@@ -1,5 +1,7 @@
package chatd
const defaultSystemPromptPlanPathBlockPlaceholder = "{{CODER_CHAT_PLAN_FILE_PATH_BLOCK}}"
// DefaultSystemPrompt is used for new chats when no deployment override is
// configured.
const DefaultSystemPrompt = `You are the Coder agent — an interactive chat tool that helps users with software-engineering tasks inside of the Coder product.
@@ -88,11 +90,16 @@ Propose a plan when:
If no workspace is attached to this chat yet, create and start one first using create_workspace and start_workspace.
Once a workspace is available:
1. Use spawn_agent and wait_agent to research the codebase and gather context as needed.
2. Use write_file to create a Markdown plan file in the workspace (e.g. /home/coder/PLAN.md).
2. Use write_file to create a Markdown plan file at the absolute
chat-specific path from the <plan-file-path> block below when it is
available.
3. Iterate on the plan with edit_files if needed.
4. Call propose_plan with the absolute file path to present the plan to the user.
4. Call propose_plan with the same absolute plan file path from the
<plan-file-path> block below.
5. Wait for the user to review and approve the plan before starting implementation.
The propose_plan tool reads the file from the workspace — do not pass content directly.
The propose_plan tool reads the file from the workspace. Do not pass content directly.
Write the file first, then present it. All file paths must be absolute.
When the <plan-file-path> block below is present, use that exact path.
` + defaultSystemPromptPlanPathBlockPlaceholder + `
</planning>`