From a554de372ae68de4813e7717894304802404eccb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:50:40 +0200 Subject: [PATCH] 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 `` 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. --- coderd/x/chatd/chatd.go | 133 ++++++++ coderd/x/chatd/chatd_test.go | 28 ++ coderd/x/chatd/chattool/editfiles.go | 35 +- coderd/x/chatd/chattool/editfiles_test.go | 290 ++++++++++++++++ coderd/x/chatd/chattool/planpath.go | 90 +++++ .../chatd/chattool/planpath_internal_test.go | 132 +++++++ coderd/x/chatd/chattool/planpath_test.go | 219 ++++++++++++ coderd/x/chatd/chattool/planpathmessage.go | 54 +++ coderd/x/chatd/chattool/proposeplan.go | 37 +- coderd/x/chatd/chattool/proposeplan_test.go | 228 ++++++++++++- coderd/x/chatd/chattool/writefile.go | 23 +- coderd/x/chatd/chattool/writefile_test.go | 321 ++++++++++++++++++ coderd/x/chatd/instruction_test.go | 102 ++++++ coderd/x/chatd/prompt.go | 13 +- .../tools/ProposePlanTool.stories.tsx | 36 +- 15 files changed, 1708 insertions(+), 33 deletions(-) create mode 100644 coderd/x/chatd/chattool/editfiles_test.go create mode 100644 coderd/x/chatd/chattool/planpath.go create mode 100644 coderd/x/chatd/chattool/planpath_internal_test.go create mode 100644 coderd/x/chatd/chattool/planpath_test.go create mode 100644 coderd/x/chatd/chattool/planpathmessage.go create mode 100644 coderd/x/chatd/chattool/writefile_test.go diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 454d8eb3a6..f17d7e8c9f 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -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 "\n" + trimmed + "\n" } +// 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("\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("") + return b.String() +} + func (p *Server) recoverStaleChats(ctx context.Context) { staleAfter := time.Now().Add(-p.inFlightChatStaleAfter) staleChats, err := p.db.GetStaleChats(ctx, staleAfter) diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 6b31fa8ad7..483d4667d5 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -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, + "\nYour plan file path for this chat is:", + ) + trimmed := strings.TrimSpace(msg.Content) + if strings.HasPrefix(trimmed, "") && + strings.HasSuffix(trimmed, "") { + standalonePlanBlockCount++ + } + } + require.Contains(t, allSystemContent, "", "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, "", + "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") } diff --git a/coderd/x/chatd/chattool/editfiles.go b/coderd/x/chatd/chattool/editfiles.go index 1d601efb53..100598e13d 100644 --- a/coderd/x/chatd/chattool/editfiles.go +++ b/coderd/x/chatd/chattool/editfiles.go @@ -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 } diff --git a/coderd/x/chatd/chattool/editfiles_test.go b/coderd/x/chatd/chattool/editfiles_test.go new file mode 100644 index 0000000000..d16be9588a --- /dev/null +++ b/coderd/x/chatd/chattool/editfiles_test.go @@ -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) + }) +} diff --git a/coderd/x/chatd/chattool/planpath.go b/coderd/x/chatd/chattool/planpath.go new file mode 100644 index 0000000000..964dde0d03 --- /dev/null +++ b/coderd/x/chatd/chattool/planpath.go @@ -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) +} diff --git a/coderd/x/chatd/chattool/planpath_internal_test.go b/coderd/x/chatd/chattool/planpath_internal_test.go new file mode 100644 index 0000000000..48f769dcd8 --- /dev/null +++ b/coderd/x/chatd/chattool/planpath_internal_test.go @@ -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"), + ) +} diff --git a/coderd/x/chatd/chattool/planpath_test.go b/coderd/x/chatd/chattool/planpath_test.go new file mode 100644 index 0000000000..3857dd0327 --- /dev/null +++ b/coderd/x/chatd/chattool/planpath_test.go @@ -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) + }) + } +} diff --git a/coderd/x/chatd/chattool/planpathmessage.go b/coderd/x/chatd/chattool/planpathmessage.go new file mode 100644 index 0000000000..a6845a82d2 --- /dev/null +++ b/coderd/x/chatd/chattool/planpathmessage.go @@ -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, + ) +} diff --git a/coderd/x/chatd/chattool/proposeplan.go b/coderd/x/chatd/chattool/proposeplan.go index 3704e53a43..bc46e7392c 100644 --- a/coderd/x/chatd/chattool/proposeplan.go +++ b/coderd/x/chatd/chattool/proposeplan.go @@ -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", diff --git a/coderd/x/chatd/chattool/proposeplan_test.go b/coderd/x/chatd/chattool/proposeplan_test.go index 1d8113d082..92b645818c 100644 --- a/coderd/x/chatd/chattool/proposeplan_test.go +++ b/coderd/x/chatd/chattool/proposeplan_test.go @@ -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() diff --git a/coderd/x/chatd/chattool/writefile.go b/coderd/x/chatd/chattool/writefile.go index a9c372ca48..983b387209 100644 --- a/coderd/x/chatd/chattool/writefile.go +++ b/coderd/x/chatd/chattool/writefile.go @@ -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 diff --git a/coderd/x/chatd/chattool/writefile_test.go b/coderd/x/chatd/chattool/writefile_test.go new file mode 100644 index 0000000000..2fa563156e --- /dev/null +++ b/coderd/x/chatd/chattool/writefile_test.go @@ -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) + }) +} diff --git a/coderd/x/chatd/instruction_test.go b/coderd/x/chatd/instruction_test.go index ffeba4db2d..9b8f3dfc10 100644 --- a/coderd/x/chatd/instruction_test.go +++ b/coderd/x/chatd/instruction_test.go @@ -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: "\n" + defaultSystemPromptPlanPathBlockPlaceholder + "\n"}, + }, + }, + { + 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, "") + }) +} + func TestInsertSystemInstructionAfterSystemMessages(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/prompt.go b/coderd/x/chatd/prompt.go index 5f2e012544..d6764f8be7 100644 --- a/coderd/x/chatd/prompt.go +++ b/coderd/x/chatd/prompt.go @@ -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 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 + 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 block below is present, use that exact path. +` + defaultSystemPromptPlanPathBlockPlaceholder + ` ` diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.stories.tsx index ecb13ad723..35bfd83f45 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ProposePlanTool.stories.tsx @@ -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 = { title: "pages/AgentsPage/ChatElements/tools/ProposePlan", component: Tool, @@ -54,20 +58,22 @@ export default meta; type Story = StoryObj; 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",