Compare commits

...

5 Commits

12 changed files with 867 additions and 24 deletions
+59
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
@@ -4470,6 +4471,37 @@ 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
}
resolvePlanPathInstruction := 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 {
return ""
}
planPath, _, err := planPathFn(planCtx)
if err != nil {
return ""
}
return formatPlanPathInstruction(planPath)
}
// Connect to MCP servers in parallel with instruction
// resolution. ConnectAll only depends on mcpConfigs and
// mcpTokens which are available after g.Wait() above.
@@ -4663,6 +4695,9 @@ func (p *Server) runChat(
if instruction != "" {
prompt = chatprompt.InsertSystem(prompt, instruction)
}
if planPathInstruction := resolvePlanPathInstruction(ctx); planPathInstruction != "" {
prompt = chatprompt.InsertSystem(prompt, planPathInstruction)
}
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
prompt = chatprompt.InsertSystem(prompt, skillIndex)
}
@@ -4975,9 +5010,11 @@ func (p *Server) runChat(
}),
chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
PlanPath: planPathFn,
}),
chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
PlanPath: planPathFn,
}),
chattool.Execute(chattool.ExecuteOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
@@ -5033,6 +5070,7 @@ func (p *Server) runChat(
// Plan presentation tool.
tools = append(tools, chattool.ProposePlan(chattool.ProposePlanOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
PlanPath: planPathFn,
StoreFile: func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error) {
workspaceCtx.chatStateMu.Lock()
chatSnapshot := *workspaceCtx.currentChat
@@ -5225,6 +5263,9 @@ func (p *Server) runChat(
if instruction != "" {
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, instruction)
}
if planPathInstruction := resolvePlanPathInstruction(reloadCtx); planPathInstruction != "" {
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, planPathInstruction)
}
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, skillIndex)
}
@@ -5896,6 +5937,24 @@ func (p *Server) resolveUserPrompt(ctx context.Context, userID uuid.UUID) string
return "<user-instructions>\n" + trimmed + "\n</user-instructions>"
}
func formatPlanPathInstruction(planPath string) string {
planPath = strings.TrimSpace(planPath)
if planPath == "" {
return ""
}
var b strings.Builder
_, _ = b.WriteString("<plan-file-path>\n")
_, _ = b.WriteString("Your plan file path for this chat is: ")
_, _ = b.WriteString(planPath)
_, _ = b.WriteString("\n")
_, _ = b.WriteString("Always use this exact path when creating or proposing plan files. Do not use ")
_, _ = b.WriteString(chattool.LegacySharedPlanPath)
_, _ = 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)
+22 -1
View File
@@ -10,6 +10,7 @@ import (
type EditFilesOptions struct {
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
PlanPath func(context.Context) (chatPath string, home string, err error)
}
type EditFilesArgs struct {
@@ -29,7 +30,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.PlanPath)
},
)
}
@@ -38,11 +39,31 @@ 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 _, file := range args.Files {
if resolvePlanPath == nil || !looksLikePlanFileName(file.Path) {
continue
}
if !planPathLoaded {
chatPath, home, planPathErr = resolvePlanPath(ctx)
planPathLoaded = true
}
if resp, rejected := rejectSharedPlanPath(file.Path, home, chatPath, planPathErr); rejected {
return resp, nil
}
}
if err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{Files: args.Files}); err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
+81
View File
@@ -0,0 +1,81 @@
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("RejectsHomeRootPlanPathsWhenPlanPathIsConfigured", 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
},
PlanPath: func(context.Context) (string, string, error) {
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: `{"files":[{"path":"/Users/dev/plan.md","edits":[{"search":"old","replace":"new"}]}]}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Equal(
t,
sharedPlanPathResolvedMessage("/Users/dev/.coder/plans/PLAN-chat.md"),
resp.Content,
)
})
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)
planPathCalled := false
tool := chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
PlanPath: func(context.Context) (string, string, error) {
planPathCalled = 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, planPathCalled)
})
}
+76
View File
@@ -0,0 +1,76 @@
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",
)
}
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.
func LooksLikeHomePlanFile(requestedPath, home string) bool {
cleaned := path.Clean(requestedPath)
cleanedHome := path.Clean(home)
return looksLikePlanFileName(cleaned) &&
path.Dir(cleaned) == cleanedHome
}
// IsLegacySharedPlanPath reports whether requested is the exact legacy
// shared plan file path.
func IsLegacySharedPlanPath(requested string) bool {
return requested == LegacySharedPlanPath
}
+273
View File
@@ -0,0 +1,273 @@
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 {
testCase := testCase
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 TestIsLegacySharedPlanPath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requested string
want bool
}{
{
name: "ExactMatch",
requested: "/home/coder/PLAN.md",
want: true,
},
{
name: "DifferentFilename",
requested: "/home/coder/OTHER.md",
want: false,
},
{
name: "DifferentDirectory",
requested: "/home/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,
},
{
name: "SubstringMatch",
requested: "/home/coder/PLAN.md/extra",
want: false,
},
}
for _, testCase := range tests {
testCase := testCase
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
got := chattool.IsLegacySharedPlanPath(testCase.requested)
require.Equal(t, testCase.want, got)
})
}
}
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 {
testCase := testCase
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,44 @@
package chattool
import (
"fmt"
"strings"
"charm.land/fantasy"
)
func rejectSharedPlanPath(
requestedPath string,
home string,
planPath string,
planPathErr error,
) (fantasy.ToolResponse, bool) {
if !looksLikeLegacyHomePlanPath(requestedPath, home) {
return fantasy.ToolResponse{}, false
}
return fantasy.NewTextErrorResponse(sharedPlanPathMessage(planPath, planPathErr)), true
}
func looksLikeLegacyHomePlanPath(requestedPath, home string) bool {
if strings.TrimSpace(home) == "" {
return IsLegacySharedPlanPath(requestedPath)
}
return LooksLikeHomePlanFile(requestedPath, home)
}
func sharedPlanPathMessage(planPath string, err error) string {
if err == nil && strings.TrimSpace(planPath) != "" {
return fmt.Sprintf(
"the shared plan path %s is no longer supported; use the chat-specific plan path: %s",
LegacySharedPlanPath,
planPath,
)
}
return fmt.Sprintf(
"the shared plan path %s is no longer supported; use the chat-specific plan path provided in your instructions",
LegacySharedPlanPath,
)
}
+14 -4
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)
PlanPath 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 plan path from your runtime instructions, 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.PlanPath, options.StoreFile)
},
)
}
@@ -53,16 +55,24 @@ 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
return fantasy.NewTextErrorResponse("path is required (use the chat-specific plan path from your instructions)"), nil
}
if !strings.HasSuffix(path, ".md") {
return fantasy.NewTextErrorResponse("path must end with .md"), nil
}
if resolvePlanPath != nil && looksLikePlanFileName(path) {
chatPath, home, err := resolvePlanPath(ctx)
if resp, rejected := rejectSharedPlanPath(path, home, chatPath, err); rejected {
return resp, nil
}
}
rc, _, err := conn.ReadFile(ctx, path, 0, maxProposePlanSize+1)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
+120 -1
View File
@@ -135,7 +135,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 "", "", xerrors.New("should not be called")
},
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "propose_plan",
@@ -143,6 +152,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 +164,40 @@ 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 +262,60 @@ 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("/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, sharedPlanPathFallbackMessage(), resp.Content)
})
t.Run("WorkspaceConnectionError", func(t *testing.T) {
t.Parallel()
storeFile, _ := fakeStoreFile(t)
@@ -278,16 +376,37 @@ 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),
planPath 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
},
PlanPath: planPath,
StoreFile: storeFile,
})
}
func sharedPlanPathResolvedMessage(planPath string) string {
return "the shared plan path " + chattool.LegacySharedPlanPath +
" is no longer supported; use the chat-specific plan path: " + planPath
}
func sharedPlanPathFallbackMessage() string {
return "the shared plan path " + chattool.LegacySharedPlanPath +
" is no longer supported; use the chat-specific plan path provided in your instructions"
}
func fakeStoreFile(t *testing.T) (func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error), *[]byte) {
t.Helper()
+10 -1
View File
@@ -11,6 +11,7 @@ import (
type WriteFileOptions struct {
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
PlanPath 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.PlanPath)
},
)
}
@@ -39,11 +40,19 @@ 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 == "" {
return fantasy.NewTextErrorResponse("path is required"), nil
}
if resolvePlanPath != nil && looksLikePlanFileName(args.Path) {
chatPath, home, err := resolvePlanPath(ctx)
if resp, rejected := rejectSharedPlanPath(args.Path, home, chatPath, err); rejected {
return resp, nil
}
}
if err := conn.WriteFile(ctx, args.Path, strings.NewReader(args.Content)); err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
+143
View File
@@ -0,0 +1,143 @@
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("RejectsHomeRootPlanVariantsWhenPlanPathIsConfigured", 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 {
testCase := testCase
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
},
PlanPath: 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("/home/coder/.coder/plans/PLAN-chat.md"),
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
})
planPathCalled := false
tool := chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
PlanPath: func(context.Context) (string, string, error) {
planPathCalled = 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, planPathCalled)
assert.Equal(t, `{"ok":true}`, strings.TrimSpace(resp.Content))
})
t.Run("AllowsSharedPlanPathWhenPlanPathIsNil", 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)
})
}
+3 -3
View File
@@ -88,11 +88,11 @@ 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 chat-specific plan path provided in your instructions.
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 absolute plan file path from your instructions to present the plan to the user.
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.
</planning>`
@@ -35,6 +35,10 @@ const samplePlan = [
"> **Note**: Based on [RFC 6749](https://tools.ietf.org/html/rfc6749).",
].join("\n");
const defaultPlanPath =
"/home/coder/.coder/plans/PLAN-a1b2c3d4-e5f6-7890-abcd-ef1234567890.md";
const defaultPlanFilename = defaultPlanPath.split("/").pop() ?? "PLAN.md";
const meta: Meta<typeof Tool> = {
title: "pages/AgentsPage/ChatElements/tools/ProposePlan",
component: Tool,
@@ -54,20 +58,22 @@ export default meta;
type Story = StoryObj<typeof Tool>;
export const Running: Story = {
args: { status: "running", args: { path: "/home/coder/PLAN.md" } },
args: { status: "running", args: { path: defaultPlanPath } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Proposing PLAN\.md/)).toBeInTheDocument();
expect(
canvas.getByText(`Proposing ${defaultPlanFilename}`),
).toBeInTheDocument();
},
};
export const Completed: Story = {
args: {
status: "completed",
args: { path: "/home/coder/PLAN.md" },
args: { path: defaultPlanPath },
result: {
ok: true,
path: "/home/coder/PLAN.md",
path: defaultPlanPath,
kind: "plan",
file_id: "test-file-id-completed",
media_type: "text/markdown",
@@ -138,12 +144,14 @@ export const ErrorState: Story = {
args: {
status: "completed",
isError: true,
args: { path: "/home/coder/PLAN.md" },
args: { path: defaultPlanPath },
result: "Failed to read file: file not found",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Proposed PLAN\.md/)).toBeInTheDocument();
expect(
canvas.getByText(`Proposed ${defaultPlanFilename}`),
).toBeInTheDocument();
expect(canvas.getByLabelText("Error")).toBeInTheDocument();
},
};
@@ -151,10 +159,10 @@ export const ErrorState: Story = {
export const EmptyContent: Story = {
args: {
status: "completed",
args: { path: "/home/coder/PLAN.md" },
args: { path: defaultPlanPath },
result: {
ok: true,
path: "/home/coder/PLAN.md",
path: defaultPlanPath,
kind: "plan",
file_id: "test-file-id-empty-content",
media_type: "text/markdown",
@@ -172,10 +180,10 @@ export const EmptyContent: Story = {
export const FileIDLoading: Story = {
args: {
status: "completed",
args: { path: "/home/coder/PLAN.md" },
args: { path: defaultPlanPath },
result: {
ok: true,
path: "/home/coder/PLAN.md",
path: defaultPlanPath,
kind: "plan",
file_id: "test-file-id-loading",
media_type: "text/markdown",
@@ -195,10 +203,10 @@ export const FileIDLoading: Story = {
export const FileIDCompleted: Story = {
args: {
status: "completed",
args: { path: "/home/coder/PLAN.md" },
args: { path: defaultPlanPath },
result: {
ok: true,
path: "/home/coder/PLAN.md",
path: defaultPlanPath,
kind: "plan",
file_id: "test-file-id-success",
media_type: "text/markdown",
@@ -216,10 +224,10 @@ export const FileIDCompleted: Story = {
export const FileIDFetchError: Story = {
args: {
status: "completed",
args: { path: "/home/coder/PLAN.md" },
args: { path: defaultPlanPath },
result: {
ok: true,
path: "/home/coder/PLAN.md",
path: defaultPlanPath,
kind: "plan",
file_id: "test-file-id-error",
media_type: "text/markdown",